runRemoteSyncAdapterContract function

void runRemoteSyncAdapterContract({
  1. required String adapterName,
  2. required RemoteAdapterHarness newHarness(),
  3. 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',
        );
      });
    });
  });
}