runRemoteSyncAdapterContract function
void
runRemoteSyncAdapterContract({
- required String adapterName,
- required RemoteAdapterHarness newHarness(),
- String table = 'things',
Runs the shared RemoteSyncAdapter behavioural contract against the adapter
produced by newHarness — the remote-side counterpart of
runLocalDatabaseAdapterContract.
Call it from your custom remote adapter's test file with a harness that wires the adapter to a controllable backend:
runRemoteSyncAdapterContract(
adapterName: 'MyApiAdapter',
newHarness: () => MyApiHarness(),
);
Implementation
void runRemoteSyncAdapterContract({
required String adapterName,
required RemoteAdapterHarness Function() newHarness,
String table = 'things',
}) {
group('$adapterName — RemoteSyncAdapter contract', () {
late RemoteAdapterHarness harness;
setUp(() => harness = newHarness());
tearDown(() => harness.dispose());
Map<String, dynamic> row(
String id, {
int updatedAt = 1000,
int? deletedAt,
String? name,
}) =>
{
SyncColumns.id: id,
SyncColumns.createdAt: 1000,
SyncColumns.updatedAt: updatedAt,
SyncColumns.deletedAt: deletedAt,
SyncColumns.isSynced: 1,
SyncColumns.syncStatus: 'synced',
'name': name ?? id,
};
SyncQueueEntry entry(SyncOperation op, Map<String, dynamic> payload) =>
SyncQueueEntry(
id: 'q-${payload[SyncColumns.id]}-${op.name}',
table: table,
entityId: payload[SyncColumns.id] as String,
operation: op,
payload: payload,
createdAt: DateTime.utc(2026, 1, 1),
);
Iterable<Object?> idsOf(List<Map<String, dynamic>> rows) =>
rows.map((r) => r[SyncColumns.id]);
group('pullChanges', () {
test('returns an empty list for an empty backend', () async {
expect(await harness.adapter.pullChanges(table, null), isEmpty);
});
test('returns seeded rows carrying id and updated_at', () async {
await harness.seed(table, [row('a'), row('b', updatedAt: 2000)]);
final rows = await harness.adapter.pullChanges(table, null);
expect(idsOf(rows), containsAll(<String>['a', 'b']));
for (final r in rows) {
expect(r[SyncColumns.id], isNotNull);
expect(r[SyncColumns.updatedAt], isNotNull);
}
});
});
group('pushChange', () {
test('insert lands in the backend', () async {
await harness.adapter.pushChange(
entry(SyncOperation.insert, row('a')),
);
expect(idsOf(await harness.backendRows(table)), contains('a'));
});
test('update is reflected in the backend', () async {
await harness.adapter.pushChange(
entry(SyncOperation.insert, row('a', updatedAt: 1000)),
);
await harness.adapter.pushChange(
entry(
SyncOperation.update,
row('a', updatedAt: 2000, name: 'renamed'),
),
);
final backend = await harness.backendRows(table);
final a = backend.firstWhere((r) => r[SyncColumns.id] == 'a');
expect(a['name'], 'renamed');
});
test('a pushed insert becomes pullable (round-trip)', () async {
await harness.adapter.pushChange(
entry(SyncOperation.insert, row('a')),
);
final pulled = await harness.adapter.pullChanges(table, null);
expect(idsOf(pulled), contains('a'));
});
test('delete removes or tombstones the row', () async {
await harness.adapter.pushChange(
entry(SyncOperation.insert, row('a')),
);
await harness.adapter.pushChange(
entry(SyncOperation.delete, row('a', deletedAt: 5000)),
);
final match = (await harness.backendRows(
table,
))
.where((r) => r[SyncColumns.id] == 'a');
final goneOrTombstoned =
match.isEmpty || match.first[SyncColumns.deletedAt] != null;
expect(
goneOrTombstoned,
isTrue,
reason: 'delete should remove the row or set deleted_at',
);
});
});
});
}