chore: Move the drift_offline_first to legacy in favor of Serverpod implementation (#32)
This commit is contained in:
parent
027aec5ed0
commit
cbc472523b
33 changed files with 13575 additions and 284 deletions
124
legacy/drift_offline_sync/test/crdt/crdt_data_convert_test.dart
Normal file
124
legacy/drift_offline_sync/test/crdt/crdt_data_convert_test.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
import '../utils/tables.dart';
|
||||
import 'crdt_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('Given a data object with every column type', () {
|
||||
late CrdtDatabase crdtDb;
|
||||
late TableWithEveryColumnTypeData createdData;
|
||||
late Hlc testHlc;
|
||||
|
||||
setUp(() async {
|
||||
createdData = _createTestData();
|
||||
(_, crdtDb, _) = database.crdtContext;
|
||||
testHlc = Hlc.now(crdtDb.nodeId);
|
||||
});
|
||||
|
||||
group('when converting the data to CRDT data entries', () {
|
||||
late Iterable<CrdtDataEntry> entries;
|
||||
|
||||
setUp(() async {
|
||||
entries = crdtDb.convertToCrdtDataEntry(createdData, testHlc);
|
||||
});
|
||||
|
||||
test('then it creates an entry for each column.', () async {
|
||||
expect(
|
||||
entries.map((e) => e.columnName).toSet(),
|
||||
equals(
|
||||
database.tableWithEveryColumnType.$columns.map((c) => c.$name).toSet(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('then each entry has the correct user ID.', () async {
|
||||
expect(entries.map((e) => e.userId), everyElement(equals(crdtDb.userId)));
|
||||
});
|
||||
|
||||
test('then each entry has the correct table name.', () async {
|
||||
expect(
|
||||
entries.map((e) => e.tblName),
|
||||
everyElement(equals('table_with_every_column_type')),
|
||||
);
|
||||
});
|
||||
|
||||
test('then each entry has the correct HLC timestamp.', () async {
|
||||
expect(entries.map((e) => e.hlcTimestamp), everyElement(equals(testHlc)));
|
||||
});
|
||||
|
||||
test('then all entries have expected column names and values.', () async {
|
||||
final mappedData = await database.tableWithEveryColumnType.fromCrdtDataEntries(
|
||||
entries,
|
||||
);
|
||||
expect(mappedData.single, equals(createdData));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Given a changeset converted from a data object with every column type', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late TableWithEveryColumnTypeData createdData;
|
||||
late List<CrdtDataEntry> expectedChangeset;
|
||||
late Hlc testHlc;
|
||||
|
||||
setUp(() async {
|
||||
createdData = _createTestData();
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
|
||||
// Perform an insert to the database to get the exact changeset that would
|
||||
// be generated by the CRDT triggers.
|
||||
await database.tableWithEveryColumnType.insertOne(createdData);
|
||||
expectedChangeset = await crdtDb.managers.crdtDataTable
|
||||
.filter(
|
||||
(f) => f.columnName.equals(crdtDb.sqlBuilder.isDeletedColumnName).not(),
|
||||
)
|
||||
.get();
|
||||
testHlc = expectedChangeset.first.hlcTimestamp;
|
||||
await crdtDb.crdtDataTable.deleteAll();
|
||||
});
|
||||
|
||||
group('when saving the changeset to the CRDT database', () {
|
||||
late Iterable<CrdtDataEntry> entries;
|
||||
|
||||
setUp(() async {
|
||||
entries = crdtDb.convertToCrdtDataEntry(createdData, testHlc);
|
||||
await crdt.saveChangeset(entries, [testHlc]);
|
||||
});
|
||||
|
||||
test('then the saved data is equal to the generated by CRDT triggers.', () async {
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
|
||||
// The list object is not equal to the other objects because it is a
|
||||
// different instance. So we remove it from the list and compare the
|
||||
// it separately.
|
||||
final savedListObject = savedChangeset.removeEntry('a_blob');
|
||||
final expectedListObject = expectedChangeset.removeEntry('a_blob');
|
||||
|
||||
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
|
||||
expect(
|
||||
savedListObject.unwrappedValue,
|
||||
equals(expectedListObject.unwrappedValue),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
TableWithEveryColumnTypeData _createTestData() => TableWithEveryColumnTypeData(
|
||||
id: const RowId(1),
|
||||
aBool: true,
|
||||
aDateTime: DateTime.now(),
|
||||
aText: 'test',
|
||||
anInt: 1,
|
||||
anInt64: BigInt.one,
|
||||
aReal: 1.0,
|
||||
aBlob: Uint8List.fromList([1, 2, 3]),
|
||||
anIntEnum: TodoStatus.open,
|
||||
aTextWithConverter: MyCustomObject('test'),
|
||||
aUuid: UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000'),
|
||||
);
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
import '../utils/tables.dart';
|
||||
|
||||
void main() {
|
||||
group('Given CRDT database for a database with data with every column type', () {
|
||||
late CrdtDatabase crdtDb;
|
||||
late String rowId;
|
||||
late TableWithEveryColumnTypeData createdData;
|
||||
|
||||
setUp(() async {
|
||||
final createdRowId = await database.managers.tableWithEveryColumnType.create(
|
||||
(t) => t(
|
||||
aBool: const Value(true),
|
||||
aDateTime: Value(DateTime.now()),
|
||||
aText: const Value('test'),
|
||||
anInt: const Value(1),
|
||||
anInt64: Value(BigInt.one),
|
||||
aReal: const Value(1.0),
|
||||
aBlob: Value(Uint8List.fromList([1, 2, 3])),
|
||||
anIntEnum: const Value(TodoStatus.open),
|
||||
aTextWithConverter: Value(MyCustomObject('test')),
|
||||
aUuid: Value(UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000')),
|
||||
),
|
||||
);
|
||||
|
||||
(_, crdtDb, _) = database.crdtContext;
|
||||
rowId = createdRowId.toString();
|
||||
createdData = await database.managers.tableWithEveryColumnType.getSingle();
|
||||
});
|
||||
|
||||
test('when getting the data from the CRDT data table of the CRDT database '
|
||||
'then it rebuilds the data perfectly.', () async {
|
||||
final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>(
|
||||
rowId,
|
||||
);
|
||||
|
||||
expect(data, equals(createdData));
|
||||
});
|
||||
|
||||
test(
|
||||
'when trying to get the data from the CRDT data table with a non-existent row id '
|
||||
'then it returns null.',
|
||||
() async {
|
||||
final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>(
|
||||
'non-existent-row-id',
|
||||
);
|
||||
|
||||
expect(data, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('when trying to get the data from the CRDT data table with multiple row ids '
|
||||
'then it returns the data for the existing row ids.', () async {
|
||||
final data = await crdtDb.getFromCrdtData<TableWithEveryColumnTypeData>(
|
||||
[rowId, 'non-existent-row-id'],
|
||||
);
|
||||
|
||||
expect(data, equals([createdData]));
|
||||
});
|
||||
|
||||
test(
|
||||
'when trying to get the data from the CRDT data table for a non-synchronized table '
|
||||
'then it throws an error.',
|
||||
() async {
|
||||
expect(
|
||||
() => crdtDb.getSingleFromCrdtData<WithCustomTypeData>(rowId),
|
||||
throwsA(
|
||||
isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
'Table for type "WithCustomTypeData" not found in synchronized tables.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
176
legacy/drift_offline_sync/test/crdt/crdt_data_sync_test.dart
Normal file
176
legacy/drift_offline_sync/test/crdt/crdt_data_sync_test.dart
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
import '../utils/tables.dart';
|
||||
import 'crdt_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late String otherNodeId;
|
||||
|
||||
group(
|
||||
'Given CRDT for an empty database and a saved changeset during a merge operation',
|
||||
() {
|
||||
late TableWithEveryColumnTypeData testData;
|
||||
late List<CrdtDataEntry> expectedChangeset;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
otherNodeId = const Uuid().v4();
|
||||
|
||||
testData = TableWithEveryColumnTypeData(
|
||||
id: const RowId(1),
|
||||
aBool: true,
|
||||
aDateTime: DateTime.now(),
|
||||
aText: 'test',
|
||||
anInt: 1,
|
||||
anInt64: BigInt.one,
|
||||
aReal: 1.0,
|
||||
aBlob: Uint8List.fromList([1, 2, 3]),
|
||||
anIntEnum: TodoStatus.open,
|
||||
aTextWithConverter: MyCustomObject('test'),
|
||||
aUuid: UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000'),
|
||||
);
|
||||
|
||||
await database.tableWithEveryColumnType.insertOne(testData);
|
||||
await database.tableWithEveryColumnType.deleteAll();
|
||||
|
||||
expectedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
});
|
||||
|
||||
group('when syncing tables to the database', () {
|
||||
setUp(() async {
|
||||
await crdtDb.syncCrdtDataToTables();
|
||||
});
|
||||
|
||||
test(
|
||||
'then the data is inserted into the database according to the changeset.',
|
||||
() async {
|
||||
final savedData = await database.managers.tableWithEveryColumnType
|
||||
.getSingle();
|
||||
expect(savedData, equals(testData));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'then no changes are made to the CRDT data table due to the sync.',
|
||||
() async {
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
|
||||
// The list object is not equal to the other objects because it is a
|
||||
// different instance. So we remove it from the list and compare the
|
||||
// it separately.
|
||||
final savedListObject = savedChangeset.removeEntry('a_blob');
|
||||
final expectedListObject = expectedChangeset.removeEntry('a_blob');
|
||||
|
||||
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
|
||||
expect(
|
||||
savedListObject.unwrappedValue,
|
||||
equals(expectedListObject.unwrappedValue),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given CRDT for a database with data and a pending changeset with conflicting data and older HLC',
|
||||
() {
|
||||
late Iterable<CrdtDataEntry> firstChangeset;
|
||||
late Iterable<CrdtDataEntry> lateOldChangeset;
|
||||
late Hlc lastHlc;
|
||||
late Hlc lateOldHlc;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
otherNodeId = const Uuid().v4();
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
lastHlc = Hlc.now(otherNodeId);
|
||||
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
|
||||
await crdt.saveChangeset(firstChangeset, [lastHlc]);
|
||||
|
||||
lateOldHlc = Hlc.zero(otherNodeId);
|
||||
lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, lateOldHlc);
|
||||
});
|
||||
|
||||
group('when saving a changeset with older HLC', () {
|
||||
setUp(() async {
|
||||
await crdt.saveChangeset(lateOldChangeset, [lateOldHlc]);
|
||||
});
|
||||
|
||||
test('then no changes are saved to the database.', () async {
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, firstChangeset.sortedEntries);
|
||||
});
|
||||
|
||||
test('then the last received HLC is not updated in the database.', () async {
|
||||
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
|
||||
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, lastHlc);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given CRDT for a database with data and a pending changeset with conflicting data and some entries with newer HLC',
|
||||
() {
|
||||
late Iterable<CrdtDataEntry> firstChangeset;
|
||||
late Iterable<CrdtDataEntry> newerChangeset;
|
||||
late Hlc lastHlc;
|
||||
late Hlc newerHlc;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
otherNodeId = const Uuid().v4();
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
lastHlc = Hlc.now(otherNodeId);
|
||||
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
|
||||
await crdt.saveChangeset(firstChangeset, [lastHlc]);
|
||||
|
||||
newerHlc = Hlc.now(otherNodeId);
|
||||
newerChangeset = crdtDb
|
||||
.convertToCrdtDataEntry(todo, newerHlc)
|
||||
.where((e) => e.columnName == 'content');
|
||||
});
|
||||
|
||||
group('when saving a changeset with some entries with newer HLC', () {
|
||||
setUp(() async {
|
||||
await crdt.saveChangeset(newerChangeset, [newerHlc]);
|
||||
});
|
||||
|
||||
test('then the entries with newer HLC are saved to the database.', () async {
|
||||
final expectedChangeset = [
|
||||
for (final e in firstChangeset)
|
||||
if (e.columnName == 'content') newerChangeset.first else e,
|
||||
];
|
||||
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
|
||||
});
|
||||
|
||||
test('then the last received HLC is updated in the database.', () async {
|
||||
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
|
||||
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, newerHlc);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
|
||||
void main() {
|
||||
group('Given CRDT for an empty database', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late String nodeId;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, _, nodeId) = database.crdtContext;
|
||||
});
|
||||
|
||||
test('when getting the canonical time '
|
||||
'then it returns Hlc.zero for the nodeId.', () async {
|
||||
expect(await crdt.getCanonicalTime(), equals(Hlc.zero(nodeId)));
|
||||
});
|
||||
});
|
||||
|
||||
group('Given CRDT for a database with data', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late Hlc createdHlc;
|
||||
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
|
||||
);
|
||||
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
|
||||
final allCrdtDataEntries = await crdtDb.managers.crdtDataTable.get();
|
||||
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||
});
|
||||
|
||||
test('when getting the canonical time '
|
||||
'then it returns the HLC timestamp of the created data.', () async {
|
||||
expect(await crdt.getCanonicalTime(), equals(createdHlc));
|
||||
});
|
||||
});
|
||||
|
||||
group('Given CRDT for a database with data and a new data entry', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late Hlc firstCreatedHlc;
|
||||
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
|
||||
);
|
||||
|
||||
(crdt, _, _) = database.crdtContext;
|
||||
|
||||
firstCreatedHlc = await crdt.getCanonicalTime();
|
||||
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test2', targetDate: Value(DateTime.now())),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'when getting the canonical time '
|
||||
'then the canonical time is increased since the first data entry was created.',
|
||||
() async {
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
expect(canonicalTime, greaterThan(firstCreatedHlc));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
140
legacy/drift_offline_sync/test/crdt/crdt_get_changeset_test.dart
Normal file
140
legacy/drift_offline_sync/test/crdt/crdt_get_changeset_test.dart
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
|
||||
void main() {
|
||||
group('Given CRDT for an empty database', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, _, _) = database.crdtContext;
|
||||
});
|
||||
|
||||
test('when getting a changeset '
|
||||
'then it returns an empty changeset.', () async {
|
||||
final changeset = await crdt.getChangeset();
|
||||
|
||||
expect(changeset, isEmpty);
|
||||
});
|
||||
|
||||
test('when adding data to the database '
|
||||
'then the changeset contains the data.', () async {
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test'),
|
||||
);
|
||||
|
||||
final changeset = await crdt.getChangeset();
|
||||
expect(changeset, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given CRDT for a database with data', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late String nodeId;
|
||||
late String rowId;
|
||||
late Hlc createdHlc;
|
||||
late TodoEntry createdData;
|
||||
|
||||
setUp(() async {
|
||||
final createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
|
||||
);
|
||||
|
||||
(crdt, crdtDb, nodeId) = database.crdtContext;
|
||||
|
||||
final allCrdtDataEntries = await crdtDb.managers.crdtDataTable.get();
|
||||
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||
rowId = createdRowId.toString();
|
||||
createdData = await database.managers.todosTable.getSingle();
|
||||
});
|
||||
|
||||
group('when getting a changeset', () {
|
||||
group('without any filters', () {
|
||||
test(
|
||||
'then it returns a changeset with one entry per column of the created data.',
|
||||
() async {
|
||||
final changeset = await crdt.getChangeset();
|
||||
final changesetColumns = changeset.map((e) => e.columnName).toSet();
|
||||
final expectedColumns = {
|
||||
...database.todosTable.$columns.map((c) => c.$name),
|
||||
crdtDb.sqlBuilder.isDeletedColumnName,
|
||||
};
|
||||
|
||||
expect(changeset.length, expectedColumns.length);
|
||||
expect(changesetColumns, equals(expectedColumns));
|
||||
},
|
||||
);
|
||||
|
||||
test('then the raw values are set correctly.', () async {
|
||||
final changeset = await crdt.getChangeset();
|
||||
final rebuiltData = database.todosTable.map({
|
||||
for (final c in database.todosTable.$columns)
|
||||
c.$name: changeset
|
||||
.firstWhere((e) => e.rowId == rowId && e.columnName == c.$name)
|
||||
.rawValue
|
||||
?.rawSqlValue,
|
||||
});
|
||||
|
||||
expect(rebuiltData, equals(createdData));
|
||||
});
|
||||
});
|
||||
|
||||
test('with onlyNodeId set to the nodeId '
|
||||
'then it returns a changeset with the data for the nodeId.', () async {
|
||||
final changeset = await crdt.getChangeset(onlyNodeId: nodeId);
|
||||
final nodeIds = changeset.map((e) => e.hlcTimestamp.nodeId).toSet();
|
||||
|
||||
expect(changeset, isNotEmpty);
|
||||
expect(nodeIds, equals({nodeId}));
|
||||
});
|
||||
|
||||
test('with onlyNodeId set to a different nodeId '
|
||||
'then it returns an empty changeset.', () async {
|
||||
final changeset = await crdt.getChangeset(onlyNodeId: 'wrong-node');
|
||||
|
||||
expect(changeset, isEmpty);
|
||||
});
|
||||
|
||||
test('with exceptNodeId set to the nodeId '
|
||||
'then it returns an empty changeset.', () async {
|
||||
final changeset = await crdt.getChangeset(exceptNodeId: nodeId);
|
||||
|
||||
expect(changeset, isEmpty);
|
||||
});
|
||||
|
||||
test('with exceptNodeId set to a different nodeId '
|
||||
'then it returns a changeset with data from other nodes.', () async {
|
||||
const otherNodeId = 'other-node';
|
||||
final changeset = await crdt.getChangeset(exceptNodeId: otherNodeId);
|
||||
final nodeIds = changeset.map((e) => e.hlcTimestamp.nodeId).toSet();
|
||||
|
||||
expect(changeset, isNotEmpty);
|
||||
expect(nodeIds, isNot(contains(otherNodeId)));
|
||||
});
|
||||
|
||||
test(
|
||||
'with modifiedAfter set to a timestamp before the created data '
|
||||
'then it returns a changeset with the entries from the created data.',
|
||||
() async {
|
||||
final earlierHlc = Hlc.zero(nodeId);
|
||||
final changeset = await crdt.getChangeset(modifiedAfter: earlierHlc);
|
||||
|
||||
expect(changeset, isNotEmpty);
|
||||
expect(changeset.map((e) => e.rowId).toSet(), equals({rowId}));
|
||||
},
|
||||
);
|
||||
|
||||
test('with modifiedAfter set to a timestamp after the created data '
|
||||
'then it returns an empty changeset.', () async {
|
||||
final futureHlc = createdHlc.increment();
|
||||
final changeset = await crdt.getChangeset(modifiedAfter: futureHlc);
|
||||
|
||||
expect(changeset, isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
|
||||
void main() {
|
||||
group('Given CRDT for an empty database', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, _, _) = database.crdtContext;
|
||||
});
|
||||
|
||||
test('when getting the last modified timestamp '
|
||||
'then it returns null since the database is empty.', () async {
|
||||
final lastModified = await crdt.getLastModified();
|
||||
expect(lastModified, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given CRDT for a database with data', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late String nodeId;
|
||||
late Hlc createdHlc;
|
||||
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
|
||||
);
|
||||
|
||||
(crdt, crdtDb, nodeId) = database.crdtContext;
|
||||
|
||||
final allCrdtDataEntries = await crdtDb.managers.crdtDataTable.get();
|
||||
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||
});
|
||||
|
||||
group('when getting the last modified timestamp', () {
|
||||
test('without any filters '
|
||||
'then it returns the HLC timestamp of the created data.', () async {
|
||||
final lastModified = await crdt.getLastModified();
|
||||
expect(lastModified, equals(createdHlc));
|
||||
});
|
||||
|
||||
test('with onlyNodeId set to the nodeId '
|
||||
'then it returns the HLC timestamp of the created data.', () async {
|
||||
final lastModified = await crdt.getLastModified(onlyNodeId: nodeId);
|
||||
expect(lastModified, equals(createdHlc));
|
||||
});
|
||||
|
||||
test('with onlyNodeId set to a different nodeId '
|
||||
'then it returns null.', () async {
|
||||
final lastModified = await crdt.getLastModified(onlyNodeId: 'other-node');
|
||||
expect(lastModified, isNull);
|
||||
});
|
||||
|
||||
test('with exceptNodeId set to the nodeId '
|
||||
'then it returns null.', () async {
|
||||
final lastModified = await crdt.getLastModified(exceptNodeId: nodeId);
|
||||
expect(lastModified, isNull);
|
||||
});
|
||||
|
||||
test('with exceptNodeId set to a different nodeId '
|
||||
'then it returns the HLC timestamp of the created data.', () async {
|
||||
final lastModified = await crdt.getLastModified(exceptNodeId: 'other-node');
|
||||
expect(lastModified, equals(createdHlc));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
|
||||
void main() {
|
||||
group('Given CRDT with an existing canonical time', () {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late Hlc initialHlc;
|
||||
|
||||
const otherNodeId = 'other';
|
||||
final initialDateTime = DateTime.now().toUtc();
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
|
||||
await crdtDb.managers.crdtHlcStateTable.create(
|
||||
(t) => t(
|
||||
userId: crdt.userId,
|
||||
lastTimestamp: initialDateTime.millisecondsSinceEpoch,
|
||||
counter: 17,
|
||||
),
|
||||
);
|
||||
|
||||
initialHlc = await crdt.getCanonicalTime();
|
||||
});
|
||||
|
||||
test('when merging an HLC with higher time and counter '
|
||||
'then the canonical time has the new HLC timestamp and counter.', () async {
|
||||
final newHlc = Hlc(initialDateTime.advance(), 18, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(newHlc.copyWith(nodeId: crdt.nodeId)));
|
||||
});
|
||||
|
||||
test('when merging an HLC with higher time and lower counter '
|
||||
'then the canonical time has the new HLC timestamp and counter.', () async {
|
||||
final newHlc = Hlc(initialDateTime.advance(), 0, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(newHlc.copyWith(nodeId: crdt.nodeId)));
|
||||
});
|
||||
|
||||
test('when merging an HLC with lower time and counter '
|
||||
'then the canonical time is the initial HLC.', () async {
|
||||
final newHlc = Hlc(initialDateTime.retreat(), 0, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(initialHlc));
|
||||
});
|
||||
|
||||
test('when merging an HLC with lower time and higher counter '
|
||||
'then the canonical time is the initial HLC.', () async {
|
||||
final newHlc = Hlc(initialDateTime.retreat(), 18, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(initialHlc));
|
||||
});
|
||||
|
||||
test('when merging an HLC with same time and higher counter '
|
||||
'then the canonical time has the higher counter.', () async {
|
||||
final newHlc = Hlc(initialDateTime, 18, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(newHlc.copyWith(nodeId: crdt.nodeId)));
|
||||
});
|
||||
|
||||
test('when merging an HLC with same time and lower counter '
|
||||
'then the canonical time is the initial HLC.', () async {
|
||||
final newHlc = Hlc(initialDateTime, 0, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(initialHlc));
|
||||
});
|
||||
|
||||
test('when merging an HLC with same time and counter '
|
||||
'then the canonical time is the initial HLC.', () async {
|
||||
final newHlc = Hlc(initialDateTime, 17, otherNodeId);
|
||||
|
||||
await crdt.mergeCanonicalTime(newHlc);
|
||||
final canonicalTime = await crdt.getCanonicalTime();
|
||||
|
||||
expect(canonicalTime, equals(initialHlc));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on DateTime {
|
||||
DateTime advance() => add(const Duration(milliseconds: 1));
|
||||
DateTime retreat() => subtract(const Duration(milliseconds: 1));
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
import '../utils/tables.dart';
|
||||
import 'crdt_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late String nodeId;
|
||||
|
||||
group('Given CRDT for an empty database and a pending changeset', () {
|
||||
late Iterable<CrdtDataEntry> changeset;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, nodeId) = database.crdtContext;
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
changeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
|
||||
});
|
||||
|
||||
test('when merging a changeset '
|
||||
'then the changeset is saved to the database.', () async {
|
||||
await crdt.merge(changeset);
|
||||
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, changeset.sortedEntries);
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'Given CRDT for a database with data and a pending changeset with conflicting data and older HLC',
|
||||
() {
|
||||
late Iterable<CrdtDataEntry> firstChangeset;
|
||||
late Iterable<CrdtDataEntry> lateOldChangeset;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, nodeId) = database.crdtContext;
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
|
||||
await crdt.merge(firstChangeset);
|
||||
|
||||
lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.zero(nodeId));
|
||||
});
|
||||
|
||||
test('when merging a changeset with older HLC '
|
||||
'then no changes are saved to the database.', () async {
|
||||
await crdt.merge(lateOldChangeset);
|
||||
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, firstChangeset.sortedEntries);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given CRDT for a database with data and a pending changeset with conflicting data and some entries with newer HLC',
|
||||
() {
|
||||
late Iterable<CrdtDataEntry> firstChangeset;
|
||||
late Iterable<CrdtDataEntry> newerChangeset;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, nodeId) = database.crdtContext;
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
|
||||
await crdt.merge(firstChangeset);
|
||||
|
||||
newerChangeset = crdtDb
|
||||
.convertToCrdtDataEntry(todo, Hlc.now(nodeId))
|
||||
.where((e) => e.columnName == 'content');
|
||||
});
|
||||
|
||||
test('when merging a changeset with some entries with newer HLC '
|
||||
'then the entries with newer HLC are saved to the database.', () async {
|
||||
await crdt.merge(newerChangeset);
|
||||
|
||||
final expectedChangeset = [
|
||||
for (final e in firstChangeset)
|
||||
if (e.columnName == 'content') newerChangeset.first else e,
|
||||
];
|
||||
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../utils/crdt_context.dart';
|
||||
import '../utils/database.dart';
|
||||
import '../utils/tables.dart';
|
||||
import 'crdt_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
late OfflineSyncCrdt crdt;
|
||||
late CrdtDatabase crdtDb;
|
||||
late String otherNodeId;
|
||||
|
||||
group('Given CRDT for an empty database and a pending changeset', () {
|
||||
late Iterable<CrdtDataEntry> changeset;
|
||||
late Hlc lastHlc;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
otherNodeId = const Uuid().v4();
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
changeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(otherNodeId));
|
||||
lastHlc = Hlc.now(otherNodeId);
|
||||
});
|
||||
|
||||
group('when saving a changeset', () {
|
||||
setUp(() async {
|
||||
await crdt.saveChangeset(changeset, [lastHlc]);
|
||||
});
|
||||
|
||||
test('then the changeset is saved to the database.', () async {
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, changeset.sortedEntries);
|
||||
});
|
||||
|
||||
test('then the merged HLCs are saved to the database.', () async {
|
||||
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
|
||||
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, lastHlc);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'Given CRDT for a database with data and a pending changeset with conflicting data and older HLC',
|
||||
() {
|
||||
late Iterable<CrdtDataEntry> firstChangeset;
|
||||
late Iterable<CrdtDataEntry> lateOldChangeset;
|
||||
late Hlc lastHlc;
|
||||
late Hlc lateOldHlc;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
otherNodeId = const Uuid().v4();
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
lastHlc = Hlc.now(otherNodeId);
|
||||
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
|
||||
await crdt.saveChangeset(firstChangeset, [lastHlc]);
|
||||
|
||||
lateOldHlc = Hlc.zero(otherNodeId);
|
||||
lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, lateOldHlc);
|
||||
});
|
||||
|
||||
group('when saving a changeset with older HLC', () {
|
||||
setUp(() async {
|
||||
await crdt.saveChangeset(lateOldChangeset, [lateOldHlc]);
|
||||
});
|
||||
|
||||
test('then no changes are saved to the database.', () async {
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, firstChangeset.sortedEntries);
|
||||
});
|
||||
|
||||
test('then the last received HLC is not updated in the database.', () async {
|
||||
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
|
||||
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, lastHlc);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given CRDT for a database with data and a pending changeset with conflicting data and some entries with newer HLC',
|
||||
() {
|
||||
late Iterable<CrdtDataEntry> firstChangeset;
|
||||
late Iterable<CrdtDataEntry> newerChangeset;
|
||||
late Hlc lastHlc;
|
||||
late Hlc newerHlc;
|
||||
|
||||
setUp(() async {
|
||||
(crdt, crdtDb, _) = database.crdtContext;
|
||||
otherNodeId = const Uuid().v4();
|
||||
|
||||
final todo = TodoEntry(
|
||||
id: const RowId(1),
|
||||
content: 'test',
|
||||
targetDate: DateTime.now(),
|
||||
);
|
||||
|
||||
lastHlc = Hlc.now(otherNodeId);
|
||||
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
|
||||
await crdt.saveChangeset(firstChangeset, [lastHlc]);
|
||||
|
||||
newerHlc = Hlc.now(otherNodeId);
|
||||
newerChangeset = crdtDb
|
||||
.convertToCrdtDataEntry(todo, newerHlc)
|
||||
.where((e) => e.columnName == 'content');
|
||||
});
|
||||
|
||||
group('when saving a changeset with some entries with newer HLC', () {
|
||||
setUp(() async {
|
||||
await crdt.saveChangeset(newerChangeset, [newerHlc]);
|
||||
});
|
||||
|
||||
test('then the entries with newer HLC are saved to the database.', () async {
|
||||
final expectedChangeset = [
|
||||
for (final e in firstChangeset)
|
||||
if (e.columnName == 'content') newerChangeset.first else e,
|
||||
];
|
||||
|
||||
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
|
||||
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
|
||||
});
|
||||
|
||||
test('then the last received HLC is updated in the database.', () async {
|
||||
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
|
||||
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, newerHlc);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
27
legacy/drift_offline_sync/test/crdt/crdt_test_utils.dart
Normal file
27
legacy/drift_offline_sync/test/crdt/crdt_test_utils.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
|
||||
extension CrdtDataEntryListExtensions on List<CrdtDataEntry> {
|
||||
CrdtDataEntry removeEntry(String columnName) {
|
||||
final entry = firstWhere((e) => e.columnName == columnName);
|
||||
remove(entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helpers for CRDT tests.
|
||||
///
|
||||
/// Use [sortedEntries] when comparing [CrdtDataEntry] lists, since entry order
|
||||
/// is not guaranteed by the API or database.
|
||||
extension CrdtDataEntryTestExtensions on Iterable<CrdtDataEntry> {
|
||||
/// Returns a list of entries sorted by (tblName, rowId, columnName) for
|
||||
/// order-independent comparison in tests.
|
||||
List<CrdtDataEntry> get sortedEntries {
|
||||
return toList()..sort((a, b) {
|
||||
final c = a.tblName.compareTo(b.tblName);
|
||||
if (c != 0) return c;
|
||||
final r = a.rowId.compareTo(b.rowId);
|
||||
if (r != 0) return r;
|
||||
return a.columnName.compareTo(b.columnName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/executor.dart';
|
||||
import '../utils/migration.dart';
|
||||
import '../utils/user.dart';
|
||||
|
||||
void main() {
|
||||
group(
|
||||
'Given a non-opened CRDT-enabled user database with pending migrations and the CRDT database being opened first',
|
||||
() {
|
||||
late FirstDb firstDb;
|
||||
late SecondDb secondDb;
|
||||
late String fileName;
|
||||
|
||||
setUp(() async {
|
||||
fileName = 'migration_test_$testNodeId.db';
|
||||
|
||||
setTestFileExecutor(fileName);
|
||||
firstDb = FirstDb(testExecutor);
|
||||
await firstDb.managers.todosTable.limit(1).get();
|
||||
await firstDb.close();
|
||||
|
||||
setTestFileExecutor(fileName);
|
||||
secondDb = SecondDb(testExecutor);
|
||||
|
||||
final migrator = secondDb.createMigrator();
|
||||
await migrator.crdtDb.managers.crdtControlTable.limit(1).get();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await secondDb.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'when performing an operation on the user database after schema version upgrade '
|
||||
'then migrations were applied as expected.',
|
||||
() async {
|
||||
expect(secondDb.didUpgrade, isTrue);
|
||||
await expectLater(secondDb.managers.users.limit(1).get(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when getting the actual schema version of the user database after migrations were applied '
|
||||
'then the saved schema version is the same as the expected schema version.',
|
||||
() async {
|
||||
final pragma = await secondDb.customSelect('PRAGMA user_version').getSingle();
|
||||
expect(pragma.read<int>('user_version'), equals(2));
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'when opening the CRDT database after the second migration was applied',
|
||||
() {
|
||||
setUp(() async {
|
||||
await secondDb.close();
|
||||
setTestFileExecutor(fileName);
|
||||
secondDb = SecondDb(testExecutor);
|
||||
|
||||
final migrator = secondDb.createMigrator();
|
||||
await migrator.crdtDb.managers.crdtControlTable.limit(1).get();
|
||||
});
|
||||
|
||||
test('then no migrations were applied.', () async {
|
||||
expect(secondDb.didUpgrade, isFalse);
|
||||
});
|
||||
|
||||
test('then the schema version is the expected schema version.', () async {
|
||||
final pragma = await secondDb
|
||||
.customSelect('PRAGMA user_version')
|
||||
.getSingle();
|
||||
expect(pragma.read<int>('user_version'), equals(2));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when opening the user database after the second migration was applied '
|
||||
'then no migrations were applied.',
|
||||
() async {
|
||||
await secondDb.close();
|
||||
setTestFileExecutor(fileName);
|
||||
secondDb = SecondDb(testExecutor);
|
||||
|
||||
await secondDb.managers.users.limit(1).get();
|
||||
|
||||
expect(secondDb.didUpgrade, isFalse);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/database.dart';
|
||||
|
||||
void main() {
|
||||
group(
|
||||
'Given a non-opened CRDT-enabled user database and the CRDT database being opened first',
|
||||
() {
|
||||
late CrdtDatabase crdtDb;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
crdtDb = migrator.crdtDb;
|
||||
await crdtDb.managers.crdtControlTable.limit(1).get();
|
||||
});
|
||||
|
||||
test(
|
||||
'when performing an operation on the user database '
|
||||
'then the user database is also opened and no missing table errors occur.',
|
||||
() async {
|
||||
await expectLater(
|
||||
database.managers.todosTable.limit(1).get(),
|
||||
completes,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when getting the main record from the CRDT control table '
|
||||
'then the record exists as expected.',
|
||||
() async {
|
||||
final record = await crdtDb.managers.crdtControlTable.limit(1).get();
|
||||
|
||||
expect(record, isNotEmpty);
|
||||
expect(record.first.userId, equals(crdtDb.userId));
|
||||
expect(record.first.nodeId, equals(crdtDb.nodeId));
|
||||
expect(record.first.crdtTriggersOn, isTrue);
|
||||
expect(record.first.schemaVersion, equals(crdtDb.schemaVersion));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'Given a non-opened CRDT-enabled user database and the user database being opened first',
|
||||
() {
|
||||
late CrdtDatabase crdtDb;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
crdtDb = migrator.crdtDb;
|
||||
await database.managers.todosTable.limit(1).get();
|
||||
});
|
||||
|
||||
test(
|
||||
'when performing an operation on the CRDT database '
|
||||
'then the CRDT database is also opened and no missing table errors occur.',
|
||||
() async {
|
||||
await expectLater(crdtDb.managers.crdtControlTable.limit(1).get(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when getting the main record from the CRDT control table '
|
||||
'then the record exists as expected.',
|
||||
() async {
|
||||
final record = await crdtDb.managers.crdtControlTable.limit(1).get();
|
||||
|
||||
expect(record, isNotEmpty);
|
||||
expect(record.first.userId, equals(crdtDb.userId));
|
||||
expect(record.first.nodeId, equals(crdtDb.nodeId));
|
||||
expect(record.first.crdtTriggersOn, isTrue);
|
||||
expect(record.first.schemaVersion, equals(crdtDb.schemaVersion));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
133
legacy/drift_offline_sync/test/hlc/hlc_comparison_test.dart
Normal file
133
legacy/drift_offline_sync/test/hlc/hlc_comparison_test.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
|
||||
|
||||
void main() {
|
||||
group('Given two Hlcs with same dateTime, counter and nodeId when comparing ', () {
|
||||
final hlc1 = Hlc(_dateTime, 17, 'abc');
|
||||
final hlc2 = Hlc(_dateTime, 17, 'abc');
|
||||
|
||||
test('then they are equal.', () {
|
||||
expect(hlc1, hlc2);
|
||||
expect(hlc1 == hlc2, isTrue);
|
||||
expect(hlc1, equals(hlc2));
|
||||
});
|
||||
|
||||
test('then compareTo returns 0.', () {
|
||||
expect(hlc1.compareTo(hlc2), 0);
|
||||
});
|
||||
|
||||
test('then <= is true.', () {
|
||||
expect(hlc1 <= hlc2, isTrue);
|
||||
expect(hlc1, lessThanOrEqualTo(hlc2));
|
||||
});
|
||||
|
||||
test('then >= is true.', () {
|
||||
expect(hlc1 >= hlc2, isTrue);
|
||||
expect(hlc1, greaterThanOrEqualTo(hlc2));
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'Given two Hlcs with same dateTime and counter and different nodeId when comparing ',
|
||||
() {
|
||||
final hlc1 = Hlc(_dateTime, 17, 'abc');
|
||||
final hlc2 = Hlc(_dateTime, 17, 'abb');
|
||||
|
||||
test('then they are not equal.', () {
|
||||
expect(hlc1, isNot(hlc2));
|
||||
});
|
||||
|
||||
test('then compareTo returns 1.', () {
|
||||
expect(hlc1.compareTo(hlc2), 1);
|
||||
});
|
||||
|
||||
test('then lexicographically greater nodeId is greater.', () {
|
||||
expect(hlc1 > hlc2, isTrue);
|
||||
expect(hlc1, greaterThan(hlc2));
|
||||
});
|
||||
|
||||
test('then >= is true.', () {
|
||||
expect(hlc1 >= hlc2, isTrue);
|
||||
expect(hlc1, greaterThanOrEqualTo(hlc2));
|
||||
});
|
||||
|
||||
test('then < is false.', () {
|
||||
expect(hlc1 < hlc2, isFalse);
|
||||
});
|
||||
|
||||
test('then <= is false.', () {
|
||||
expect(hlc1 <= hlc2, isFalse);
|
||||
expect(hlc1, isNot(lessThanOrEqualTo(hlc2)));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group('Given two Hlcs with same dateTime and different counter when comparing ', () {
|
||||
final hlc1 = Hlc(_dateTime, 17, 'abc');
|
||||
final hlc2 = Hlc(_dateTime, 18, 'abc');
|
||||
|
||||
test('then they are not equal.', () {
|
||||
expect(hlc1, isNot(hlc2));
|
||||
});
|
||||
|
||||
test('then compareTo returns -1.', () {
|
||||
expect(hlc1.compareTo(hlc2), -1);
|
||||
});
|
||||
|
||||
test('then < is true.', () {
|
||||
expect(hlc1 < hlc2, isTrue);
|
||||
expect(hlc1, lessThan(hlc2));
|
||||
});
|
||||
|
||||
test('then <= is true.', () {
|
||||
expect(hlc1 <= hlc2, isTrue);
|
||||
expect(hlc1, lessThanOrEqualTo(hlc2));
|
||||
});
|
||||
|
||||
test('then > is false.', () {
|
||||
expect(hlc1 > hlc2, isFalse);
|
||||
});
|
||||
|
||||
test('then >= is false.', () {
|
||||
expect(hlc1 >= hlc2, isFalse);
|
||||
expect(hlc1, isNot(greaterThanOrEqualTo(hlc2)));
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'Given two Hlcs with different dateTime when comparing ',
|
||||
() {
|
||||
final hlc1 = Hlc(_dateTime, 0, 'abc');
|
||||
final hlc2 = Hlc(_dateTime.add(const Duration(milliseconds: 1)), 17, 'abc');
|
||||
|
||||
test('then they are not equal.', () {
|
||||
expect(hlc1, isNot(hlc2));
|
||||
});
|
||||
|
||||
test('then compareTo returns -1.', () {
|
||||
expect(hlc1.compareTo(hlc2), -1);
|
||||
});
|
||||
|
||||
test('then < is true.', () {
|
||||
expect(hlc1 < hlc2, isTrue);
|
||||
expect(hlc1, lessThan(hlc2));
|
||||
});
|
||||
|
||||
test('then <= is true.', () {
|
||||
expect(hlc1 <= hlc2, isTrue);
|
||||
expect(hlc1, lessThanOrEqualTo(hlc2));
|
||||
});
|
||||
|
||||
test('then > is false.', () {
|
||||
expect(hlc1 > hlc2, isFalse);
|
||||
});
|
||||
|
||||
test('then >= is false.', () {
|
||||
expect(hlc1 >= hlc2, isFalse);
|
||||
expect(hlc1, isNot(greaterThanOrEqualTo(hlc2)));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
27
legacy/drift_offline_sync/test/hlc/hlc_converter_test.dart
Normal file
27
legacy/drift_offline_sync/test/hlc/hlc_converter_test.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _isoTime = '2001-09-09T01:46:40.000Z';
|
||||
final _dateTime = DateTime.parse(_isoTime);
|
||||
|
||||
void main() {
|
||||
test('Given an HLC and the HlcConverter '
|
||||
'when converting to the database format '
|
||||
'then it returns string with padded unix timestamp.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
|
||||
expect(
|
||||
hlcConverter.toSql(hlc),
|
||||
'${_dateTime.millisecondsSinceEpoch.toString().padLeft(15, '0')}-0017-abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('Given an HLC and the HlcConverter '
|
||||
'when converting from the database format '
|
||||
'then it returns the equivalent HLC.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
final databaseHlc = hlcConverter.toSql(hlc);
|
||||
|
||||
expect(hlcConverter.fromSql(databaseHlc), hlc);
|
||||
});
|
||||
}
|
||||
29
legacy/drift_offline_sync/test/hlc/hlc_copy_with_test.dart
Normal file
29
legacy/drift_offline_sync/test/hlc/hlc_copy_with_test.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
|
||||
|
||||
void main() {
|
||||
group('Given an HLC', () {
|
||||
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||
|
||||
test('when calling copyWith with new dateTime then result has new dateTime.', () {
|
||||
final newDateTime = _dateTime.add(const Duration(minutes: 1));
|
||||
final newHlc = hlc.copyWith(dateTime: newDateTime);
|
||||
|
||||
expect(newHlc.dateTime, newDateTime);
|
||||
});
|
||||
|
||||
test('when calling copyWith with new counter then result has new counter.', () {
|
||||
final newHlc = hlc.copyWith(counter: 18);
|
||||
|
||||
expect(newHlc.counter, 18);
|
||||
});
|
||||
|
||||
test('when calling copyWith with new nodeId then result has new nodeId.', () {
|
||||
final newHlc = hlc.copyWith(nodeId: 'abcd');
|
||||
|
||||
expect(newHlc.nodeId, 'abcd');
|
||||
});
|
||||
});
|
||||
}
|
||||
82
legacy/drift_offline_sync/test/hlc/hlc_increment_test.dart
Normal file
82
legacy/drift_offline_sync/test/hlc/hlc_increment_test.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
|
||||
|
||||
void main() {
|
||||
group('Given an HLC with lower canonical time than wall time', () {
|
||||
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||
final wallTime = _dateTime.advance();
|
||||
|
||||
test('when incrementing then dateTime becomes wall time and counter resets.', () {
|
||||
withClock(Clock.fixed(wallTime), () {
|
||||
final sendHlc = hlc.increment();
|
||||
|
||||
expect(sendHlc, isNot(hlc));
|
||||
expect(sendHlc.dateTime, wallTime);
|
||||
expect(sendHlc.counter, 0);
|
||||
expect(sendHlc.nodeId, hlc.nodeId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Given an HLC with equal canonical time and wall time', () {
|
||||
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||
final wallTime = _dateTime;
|
||||
|
||||
test('when incrementing then counter increments and dateTime is unchanged.', () {
|
||||
withClock(Clock.fixed(wallTime), () {
|
||||
final sendHlc = hlc.increment();
|
||||
|
||||
expect(sendHlc, isNot(hlc));
|
||||
expect(sendHlc.dateTime, hlc.dateTime);
|
||||
expect(sendHlc.counter, 18);
|
||||
expect(sendHlc.nodeId, hlc.nodeId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Given an HLC with higher canonical time than wall time', () {
|
||||
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||
final wallTime = _dateTime.retreat();
|
||||
|
||||
test('when incrementing then counter increments and dateTime is unchanged.', () {
|
||||
withClock(Clock.fixed(wallTime), () {
|
||||
final sendHlc = hlc.increment();
|
||||
|
||||
expect(sendHlc, isNot(hlc));
|
||||
expect(sendHlc.dateTime, hlc.dateTime);
|
||||
expect(sendHlc.counter, 18);
|
||||
expect(sendHlc.nodeId, hlc.nodeId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Given an HLC with canonical time more than one minute ahead of wall time', () {
|
||||
final hlc = Hlc(_dateTime.add(const Duration(minutes: 1, seconds: 5)), 0, 'abc');
|
||||
final wallTime = _dateTime;
|
||||
|
||||
test('when incrementing then ClockDriftException is thrown.', () {
|
||||
withClock(Clock.fixed(wallTime), () {
|
||||
expect(hlc.increment, throwsA(isA<ClockDriftException>()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Given an HLC with counter at maximum and canonical time at wall time', () {
|
||||
final hlc = Hlc(_dateTime, 0xFFFF, 'abc');
|
||||
final wallTime = _dateTime;
|
||||
|
||||
test('when incrementing at same time then OverflowException is thrown.', () {
|
||||
withClock(Clock.fixed(wallTime), () {
|
||||
expect(hlc.increment, throwsA(isA<OverflowException>()));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on DateTime {
|
||||
DateTime advance() => add(const Duration(milliseconds: 1));
|
||||
DateTime retreat() => subtract(const Duration(milliseconds: 1));
|
||||
}
|
||||
106
legacy/drift_offline_sync/test/hlc/hlc_merge_test.dart
Normal file
106
legacy/drift_offline_sync/test/hlc/hlc_merge_test.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _dateTime = DateTime.now().toUtc();
|
||||
|
||||
void main() {
|
||||
group('Given local canonical Hlc and remote with higher time', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime.advance(), 2, 'abd');
|
||||
|
||||
test('when merging then result has remote time and counter with local nodeId.', () {
|
||||
final hlc = canonical.merge(remote);
|
||||
|
||||
expect(hlc, Hlc(remote.dateTime, remote.counter, canonical.nodeId));
|
||||
});
|
||||
});
|
||||
|
||||
group('Given local canonical Hlc and remote with same time but higher counter', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime, 18, 'abd');
|
||||
|
||||
test('when merging then result has remote counter with local nodeId.', () {
|
||||
final hlc = canonical.merge(remote);
|
||||
|
||||
expect(hlc, Hlc(canonical.dateTime, remote.counter, canonical.nodeId));
|
||||
});
|
||||
});
|
||||
|
||||
group('Given local canonical Hlc and remote with same time and counter', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime, 17, 'abd');
|
||||
|
||||
test('when merging then local is returned.', () {
|
||||
final hlc = canonical.merge(remote);
|
||||
|
||||
expect(hlc, equals(canonical));
|
||||
});
|
||||
});
|
||||
|
||||
group('Given local canonical Hlc and remote with lower time', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime.retreat(), 17, 'abcd');
|
||||
|
||||
test('when merging then local is returned.', () {
|
||||
final hlc = canonical.merge(remote);
|
||||
|
||||
expect(hlc, equals(canonical));
|
||||
});
|
||||
});
|
||||
|
||||
group('Given local canonical Hlc and remote with same nodeId and same time', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime, 17, 'abc');
|
||||
|
||||
test('when merging then node id is not checked and local is returned.', () {
|
||||
expect(canonical.merge(remote), canonical);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given local canonical Hlc and remote with same nodeId and lower time', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime.retreat(), 17, 'abc');
|
||||
|
||||
test('when merging then node id is not checked and local is returned.', () {
|
||||
expect(canonical.merge(remote), canonical);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given local canonical Hlc and remote with same nodeId and higher time', () {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime.advance(), 0, 'abc');
|
||||
|
||||
test('when merging then DuplicateNodeException is thrown.', () {
|
||||
expect(
|
||||
() => canonical.merge(remote),
|
||||
throwsA(isA<DuplicateNodeException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'Given local canonical Hlc and remote with time more than one minute ahead of wall',
|
||||
() {
|
||||
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||
final remote = Hlc(_dateTime.advance(const Duration(minutes: 2)), 17, 'abd');
|
||||
|
||||
test('when merging then ClockDriftException is thrown.', () {
|
||||
withClock(Clock.fixed(_dateTime.advance()), () {
|
||||
expect(
|
||||
() => canonical.merge(remote),
|
||||
throwsA(isA<ClockDriftException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
extension on DateTime {
|
||||
DateTime advance([Duration duration = const Duration(milliseconds: 1)]) =>
|
||||
add(duration);
|
||||
|
||||
DateTime retreat([Duration duration = const Duration(milliseconds: 1)]) =>
|
||||
subtract(duration);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _isoTime = '2001-09-09T01:46:40.000Z';
|
||||
final _dateTime = DateTime.parse(_isoTime);
|
||||
|
||||
void main() {
|
||||
test('Given an HLC built with dateTime, counter and nodeId '
|
||||
'when reading properties '
|
||||
'then dateTime, counter and nodeId match.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
|
||||
expect(hlc.dateTime, _dateTime);
|
||||
expect(hlc.counter, 0x17);
|
||||
expect(hlc.nodeId, 'abc');
|
||||
});
|
||||
|
||||
test('Given Hlc.zero '
|
||||
'when building for a nodeId '
|
||||
'then it equals epoch with zero counter.', () {
|
||||
final zero = Hlc.zero('abc');
|
||||
|
||||
expect(zero.unixTimestamp, 0);
|
||||
expect(zero.counter, 0);
|
||||
expect(zero.nodeId, 'abc');
|
||||
});
|
||||
|
||||
test('Given an ISO HLC string '
|
||||
'when parsing '
|
||||
'then it returns the equivalent Hlc.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
|
||||
expect(Hlc.parse('${_dateTime.toIso8601String()}-0017-abc'), hlc);
|
||||
});
|
||||
|
||||
test('Given a Unix timestamp HLC string '
|
||||
'when parsing '
|
||||
'then it returns the equivalent Hlc.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
|
||||
expect(Hlc.parse('${_dateTime.millisecondsSinceEpoch}-0017-abc'), hlc);
|
||||
});
|
||||
|
||||
test('Given a parsed Hlc '
|
||||
'when calling toString '
|
||||
'then it returns the ISO HLC string.', () {
|
||||
final hlc = Hlc.parse('$_isoTime-0017-abc');
|
||||
|
||||
expect(hlc.toString(), '$_isoTime-0017-abc');
|
||||
});
|
||||
|
||||
test('Given an HLC '
|
||||
'when converting to JSON '
|
||||
'then it returns the equivalent string.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
|
||||
expect(hlc.toJson(), '${_dateTime.toIso8601String()}-0017-abc');
|
||||
});
|
||||
|
||||
test('Given an HLC '
|
||||
'when converting to JSON with unixTimestamp '
|
||||
'then it returns the equivalent string with padded unix timestamp.', () {
|
||||
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||
|
||||
expect(
|
||||
hlc.toJson(unixTimestamp: true),
|
||||
'${_dateTime.millisecondsSinceEpoch.toString().padLeft(15, '0')}-0017-abc',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:drift_offline_sync/src/database/triggers.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/database.dart';
|
||||
|
||||
void main() {
|
||||
group('Given a database with an OfflineSyncMigrator', () {
|
||||
group('when running the migration', () {
|
||||
setUp(() async {
|
||||
// Insert a new row to trigger the migration.
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test'),
|
||||
);
|
||||
});
|
||||
|
||||
test('then the triggers are created for every synchronized table.', () async {
|
||||
final allTriggerNames = await database.getCrdtTriggers();
|
||||
|
||||
for (final table in database.createMigrator().synchronizedTables) {
|
||||
final triggerNames = allTriggerNames
|
||||
.where((name) => name.startsWith('__crdt__${table.actualTableName}__'))
|
||||
.toList();
|
||||
|
||||
expect(triggerNames, contains('__crdt__${table.actualTableName}__insert'));
|
||||
expect(triggerNames, contains('__crdt__${table.actualTableName}__update'));
|
||||
expect(triggerNames, contains('__crdt__${table.actualTableName}__delete'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Add a test for the case where a table has no primary key.
|
||||
// TODO: Add a test for the case where a table has multiple primary key columns.
|
||||
// TODO: Add a test for the case where a table has only primary key columns.
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/database.dart';
|
||||
import '../utils/tables.dart';
|
||||
|
||||
void main() {
|
||||
group('Given a database with an empty synchronized table', () {
|
||||
group('when inserting a new row on a synchronized table', () {
|
||||
const targetContent = 'test';
|
||||
final targetDate = DateTime(2026, 1, 13);
|
||||
late int createdRowId;
|
||||
late String rowId;
|
||||
|
||||
setUp(() async {
|
||||
createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(
|
||||
content: targetContent,
|
||||
targetDate: Value(targetDate),
|
||||
),
|
||||
);
|
||||
|
||||
rowId = createdRowId.toString();
|
||||
});
|
||||
|
||||
test('then the row exists in the target table.', () async {
|
||||
final row = await database.managers.todosTable.getSingle();
|
||||
|
||||
expect(row, isNotNull);
|
||||
expect(row.content, targetContent);
|
||||
expect(row.targetDate, targetDate);
|
||||
});
|
||||
|
||||
group('then the trigger is executed and the CRDT data table', () {
|
||||
late String isDeletedColumnName;
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
test('has one entry for each column of the target table.', () async {
|
||||
expect(
|
||||
allCrdtDataEntries.map((e) => e.columnName).toSet(),
|
||||
equals(
|
||||
database.todosTable.$columns.map((c) => c.$name).toSet()
|
||||
..add(isDeletedColumnName),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('has the raw values inserted correctly.', () async {
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, 'content');
|
||||
expect(crdtData.unwrappedValue, targetContent);
|
||||
|
||||
final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date');
|
||||
expect(
|
||||
targetDateCrdtData.unwrappedValue,
|
||||
targetDate.toIso8600StringWithOffset(),
|
||||
);
|
||||
});
|
||||
|
||||
test('has the same HLC timestamp for each inserted column.', () async {
|
||||
final targetHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, 'content');
|
||||
expect(crdtData.hlcTimestamp, equals(targetHlc));
|
||||
|
||||
final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date');
|
||||
expect(targetDateCrdtData.hlcTimestamp, equals(targetHlc));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Given a database with a synchronized table with a row', () {
|
||||
const initialContent = 'test';
|
||||
late int createdRowId;
|
||||
late Hlc createdHlc;
|
||||
late String rowId;
|
||||
|
||||
setUp(() async {
|
||||
createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(content: initialContent),
|
||||
);
|
||||
rowId = createdRowId.toString();
|
||||
|
||||
final migrator = database.createMigrator();
|
||||
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||
});
|
||||
|
||||
group('when updating the existing row with a different value', () {
|
||||
const targetContent = 'test2';
|
||||
final targetDate = DateTime(2026, 1, 13);
|
||||
const updatedColumnNames = ['content', 'target_date'];
|
||||
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.update(
|
||||
(t) => t(
|
||||
id: Value(RowId(createdRowId)),
|
||||
content: const Value(targetContent),
|
||||
targetDate: Value(targetDate),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('then the row has been updated in the target table.', () async {
|
||||
final row = await database.managers.todosTable.getSingle();
|
||||
|
||||
expect(row, isNotNull);
|
||||
expect(row.content, targetContent);
|
||||
expect(row.targetDate, targetDate);
|
||||
});
|
||||
|
||||
group(
|
||||
'then the trigger is executed and the updated row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
});
|
||||
|
||||
test('has the raw values updated correctly.', () async {
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, 'content');
|
||||
expect(crdtData.unwrappedValue, targetContent);
|
||||
|
||||
final targetDateCrdtData = allCrdtDataEntries.getField(
|
||||
rowId,
|
||||
'target_date',
|
||||
);
|
||||
expect(
|
||||
targetDateCrdtData.unwrappedValue,
|
||||
targetDate.toIso8600StringWithOffset(),
|
||||
);
|
||||
});
|
||||
|
||||
test('has the same HLC timestamp for each updated column.', () async {
|
||||
final updatedEntriesHlc = allCrdtDataEntries
|
||||
.where((entry) => entry.rowId == rowId)
|
||||
.where((entry) => updatedColumnNames.contains(entry.columnName))
|
||||
.map((entry) => entry.hlcTimestamp);
|
||||
|
||||
expect(updatedEntriesHlc.toSet(), hasLength(1));
|
||||
});
|
||||
|
||||
test('has the updated HLC timestamp greater than the created.', () async {
|
||||
final updatedEntry = allCrdtDataEntries.getField(rowId, 'content');
|
||||
expect(updatedEntry.hlcTimestamp, greaterThan(createdHlc));
|
||||
});
|
||||
|
||||
test('has not changed the HLC timestamp for non-updated columns.', () async {
|
||||
final otherEntriesHlc = allCrdtDataEntries
|
||||
.where((entry) => entry.rowId == rowId)
|
||||
.where((entry) => !updatedColumnNames.contains(entry.columnName))
|
||||
.map((entry) => entry.hlcTimestamp);
|
||||
|
||||
expect(otherEntriesHlc, everyElement(equals(createdHlc)));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('when updating the existing row with the same value', () {
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.update(
|
||||
(t) => t(
|
||||
id: Value(RowId(createdRowId)),
|
||||
content: const Value(initialContent),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group(
|
||||
'then the trigger is executed and the updated row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
});
|
||||
|
||||
test('has not changed the HLC timestamp for the updated column.', () async {
|
||||
final updatedEntry = allCrdtDataEntries.getField(rowId, 'content');
|
||||
expect(updatedEntry.hlcTimestamp, equals(createdHlc));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('when deleting the existing row', () {
|
||||
setUp(() async {
|
||||
await database.managers.todosTable
|
||||
.filter((t) => t.id.equals(RowId(createdRowId)))
|
||||
.delete();
|
||||
});
|
||||
|
||||
test('then the row has been deleted in the target table.', () async {
|
||||
final row = await database.managers.todosTable.getSingleOrNull();
|
||||
|
||||
expect(row, isNull);
|
||||
});
|
||||
|
||||
group(
|
||||
'then the trigger is executed and the deleted row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
late String isDeletedColumnName;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
test('has the deleted flag column set to true.', () async {
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
|
||||
expect(crdtData.unwrappedValue, 1);
|
||||
});
|
||||
|
||||
test('has the deleted HLC timestamp greater than the created.', () async {
|
||||
final deletedEntry = allCrdtDataEntries.getField(
|
||||
rowId,
|
||||
isDeletedColumnName,
|
||||
);
|
||||
expect(deletedEntry.hlcTimestamp, greaterThan(createdHlc));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given a database with a previously deleted row from a synchronized table', () {
|
||||
const initialContent = 'test';
|
||||
late String isDeletedColumnName;
|
||||
late int createdRowId;
|
||||
late Hlc deletedHlc;
|
||||
late String rowId;
|
||||
|
||||
setUp(() async {
|
||||
createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(content: initialContent),
|
||||
);
|
||||
rowId = createdRowId.toString();
|
||||
|
||||
final migrator = database.createMigrator();
|
||||
|
||||
final deleted = await database.managers.todosTable
|
||||
.filter((t) => t.id.equals(RowId(createdRowId)))
|
||||
.delete();
|
||||
expect(deleted, equals(1));
|
||||
|
||||
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
deletedHlc = allCrdtDataEntries.getField(rowId, isDeletedColumnName).hlcTimestamp;
|
||||
});
|
||||
|
||||
group('when re-inserting the previously deleted row', () {
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(
|
||||
id: Value(RowId(createdRowId)),
|
||||
content: initialContent,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'then the row has been re-inserted in the target table with the same data.',
|
||||
() async {
|
||||
final row = await database.managers.todosTable.getSingle();
|
||||
|
||||
expect(row, isNotNull);
|
||||
expect(row.id, equals(createdRowId));
|
||||
expect(row.content, initialContent);
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'then the trigger is executed and the re-inserted row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
test('has the deleted flag column set to false.', () async {
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
|
||||
expect(crdtData.unwrappedValue, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'has the deleted flag HLC timestamp greater than the deleted.',
|
||||
() async {
|
||||
final deletedEntry = allCrdtDataEntries.getField(
|
||||
rowId,
|
||||
isDeletedColumnName,
|
||||
);
|
||||
expect(deletedEntry.hlcTimestamp, greaterThan(deletedHlc));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'has the HLC timestamp updated for newly inserted columns.',
|
||||
() async {
|
||||
expect(
|
||||
allCrdtDataEntries.map((e) => e.hlcTimestamp),
|
||||
everyElement(greaterThan(deletedHlc)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given a database with an empty synchronized table and triggers disabled', () {
|
||||
group('when inserting a new row on a synchronized table', () {
|
||||
const targetContent = 'test';
|
||||
final targetDate = DateTime(2026, 1, 13);
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
await migrator.crdtDb.disableTriggers();
|
||||
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(
|
||||
content: targetContent,
|
||||
targetDate: Value(targetDate),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('then the row exists in the target table.', () async {
|
||||
final row = await database.managers.todosTable.getSingle();
|
||||
|
||||
expect(row, isNotNull);
|
||||
expect(row.content, targetContent);
|
||||
expect(row.targetDate, targetDate);
|
||||
});
|
||||
|
||||
group('then the trigger is not executed and the CRDT data table', () {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
});
|
||||
|
||||
test('has no entries.', () async {
|
||||
expect(allCrdtDataEntries, isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'Given a database with a synchronized table with a row and triggers disabled',
|
||||
() {
|
||||
late int createdRowId;
|
||||
|
||||
setUp(() async {
|
||||
createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(content: 'test'),
|
||||
);
|
||||
|
||||
final migrator = database.createMigrator();
|
||||
await migrator.crdtDb.managers.crdtDataTable.delete();
|
||||
await migrator.crdtDb.disableTriggers();
|
||||
});
|
||||
|
||||
group('when updating the existing row with a different value', () {
|
||||
const targetContent = 'test2';
|
||||
final targetDate = DateTime(2026, 1, 13);
|
||||
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.update(
|
||||
(t) => t(
|
||||
id: Value(RowId(createdRowId)),
|
||||
content: const Value('test2'),
|
||||
targetDate: Value(DateTime(2026, 1, 13)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('then the row has been updated in the target table.', () async {
|
||||
final row = await database.managers.todosTable.getSingle();
|
||||
|
||||
expect(row, isNotNull);
|
||||
expect(row.content, targetContent);
|
||||
expect(row.targetDate, targetDate);
|
||||
});
|
||||
|
||||
group(
|
||||
'then the trigger is not executed and the updated row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
});
|
||||
|
||||
test('has no entries.', () async {
|
||||
expect(allCrdtDataEntries, isEmpty);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('when deleting the existing row', () {
|
||||
setUp(() async {
|
||||
await database.managers.todosTable
|
||||
.filter((t) => t.id.equals(RowId(createdRowId)))
|
||||
.delete();
|
||||
});
|
||||
|
||||
test('then the row has been deleted in the target table.', () async {
|
||||
final row = await database.managers.todosTable.getSingleOrNull();
|
||||
|
||||
expect(row, isNull);
|
||||
});
|
||||
|
||||
group(
|
||||
'then the trigger is not executed and the deleted row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
});
|
||||
|
||||
test('has no entries.', () async {
|
||||
expect(allCrdtDataEntries, isEmpty);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
extension on Iterable<CrdtDataEntry> {
|
||||
CrdtDataEntry getField(String rowId, String fieldName) {
|
||||
return firstWhere((entry) => entry.rowId == rowId && entry.columnName == fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
extension on DateTime {
|
||||
String toIso8600StringWithOffset() {
|
||||
final offset = toLocal().timeZoneOffset.inHours;
|
||||
final sign = offset >= 0 ? '+' : '-';
|
||||
return '${toIso8601String()} $sign${offset.abs().toString().padLeft(2, '0')}:00';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue