263 lines
8.6 KiB
Dart
263 lines
8.6 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:fuel_tax_tracker/services/db_schema.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
|
|
/// Exercises the exact ATTACH + INSERT OR REPLACE merge SQL the app runs
|
|
/// (see [db_schema.dart] / CloudSyncService._pullAndMerge), against real
|
|
/// temporary SQLite files via sqflite_common_ffi — the pure-Dart backend
|
|
/// that lets sqflite run on the Dart VM for tests, without a real device.
|
|
void main() {
|
|
late Directory tempDir;
|
|
late Database local;
|
|
late Database remote;
|
|
|
|
setUpAll(() {
|
|
sqfliteFfiInit();
|
|
databaseFactory = databaseFactoryFfi;
|
|
});
|
|
|
|
setUp(() async {
|
|
tempDir = await Directory.systemTemp.createTemp('db_merge_test_');
|
|
local = await _openFreshDb(p.join(tempDir.path, 'local.db'));
|
|
remote = await _openFreshDb(p.join(tempDir.path, 'remote.db'));
|
|
});
|
|
|
|
tearDown(() async {
|
|
await local.close();
|
|
await remote.close();
|
|
await tempDir.delete(recursive: true);
|
|
});
|
|
|
|
Future<void> merge() async {
|
|
await local.execute("ATTACH DATABASE '${remote.path}' AS remote_db");
|
|
try {
|
|
await local.execute(mergeVehiclesSql);
|
|
await local.execute(mergeFuelEntriesSql);
|
|
await local.execute(mergeAdFreeEntitlementSql);
|
|
} finally {
|
|
await local.execute('DETACH DATABASE remote_db');
|
|
}
|
|
}
|
|
|
|
group('vehicle merge', () {
|
|
test('pulls in a vehicle that only exists on remote', () async {
|
|
await remote.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('vehicles');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['id'], 'v1');
|
|
expect(rows.first['vin'], 'VIN1');
|
|
expect(rows.first['dirty'], 0);
|
|
});
|
|
|
|
test('leaves a local-only dirty vehicle untouched', () async {
|
|
await local.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000, dirty: 1));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('vehicles');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['dirty'], 1);
|
|
});
|
|
|
|
test('remote wins when strictly newer than local', () async {
|
|
await local.insert('vehicles',
|
|
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'OldNickname', updatedAt: 1000, dirty: 1));
|
|
await remote.insert('vehicles',
|
|
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'NewNickname', updatedAt: 2000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('vehicles');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['nickname'], 'NewNickname');
|
|
expect(rows.first['dirty'], 0);
|
|
});
|
|
|
|
test('local wins on a tie or when strictly newer', () async {
|
|
await local.insert('vehicles',
|
|
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'MineNewer', updatedAt: 2000, dirty: 1));
|
|
await remote.insert('vehicles',
|
|
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'TheirsOlder', updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('vehicles');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['nickname'], 'MineNewer');
|
|
expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten');
|
|
});
|
|
|
|
test('a null nickname merges in fine (nickname is optional)', () async {
|
|
await remote.insert(
|
|
'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', nickname: null, updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('vehicles');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['nickname'], isNull);
|
|
});
|
|
|
|
test('the same vehicle edited on two devices merges by id, not vin', () async {
|
|
// VIN is user-editable, so it can't be the merge key — this is the
|
|
// scenario that motivated switching the merge key to a hidden id:
|
|
// the local device renamed the VIN (a legitimate edit) while the
|
|
// remote copy still has the old VIN and an older updated_at.
|
|
await local.insert('vehicles',
|
|
_vehicleRow(id: 'v1', vin: 'VIN1-CORRECTED', nickname: 'Mine', updatedAt: 2000, dirty: 1));
|
|
await remote.insert(
|
|
'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1-TYPO', nickname: 'Mine', updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('vehicles');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['vin'], 'VIN1-CORRECTED', reason: 'local was newer, so its VIN edit wins');
|
|
});
|
|
});
|
|
|
|
group('fuel entry merge', () {
|
|
test('adopts a newer tombstone from remote (deletion propagates)', () async {
|
|
await local.insert('fuel_entries',
|
|
_fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000, dirty: 0));
|
|
await remote.insert(
|
|
'fuel_entries',
|
|
_fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 2000, deletedAt: 2000),
|
|
);
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('fuel_entries');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['deleted_at'], 2000);
|
|
});
|
|
|
|
test('never adopts a receipt_image_path value from remote', () async {
|
|
// Defensive case: even if a remote row somehow had a local-looking
|
|
// path in that column, the merge must not copy it onto this device.
|
|
await remote.insert(
|
|
'fuel_entries',
|
|
_fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000)
|
|
..['receipt_image_path'] = '/some/other/devices/path.jpg',
|
|
);
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('fuel_entries');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['receipt_image_path'], isNull);
|
|
});
|
|
});
|
|
|
|
group('ad_free_entitlement merge', () {
|
|
test('pulls in the entitlement when only remote has one', () async {
|
|
await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 5000, updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('ad_free_entitlement');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['id'], 1);
|
|
expect(rows.first['ad_free_until'], 5000);
|
|
});
|
|
|
|
test('remote wins when strictly newer than local', () async {
|
|
await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000));
|
|
await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('ad_free_entitlement');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['ad_free_until'], 9000);
|
|
});
|
|
|
|
test('local wins on a tie or when strictly newer', () async {
|
|
await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000));
|
|
await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('ad_free_entitlement');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['ad_free_until'], 9000);
|
|
});
|
|
|
|
test('a null ad_free_until merges in fine (e.g. purchase consumed/lapsed remotely)', () async {
|
|
await remote.insert(
|
|
'ad_free_entitlement', _entitlementRow(adFreeUntil: null, updatedAt: 1000));
|
|
|
|
await merge();
|
|
|
|
final rows = await local.query('ad_free_entitlement');
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first['ad_free_until'], isNull);
|
|
});
|
|
|
|
test('no-op when neither side has a row yet', () async {
|
|
await merge();
|
|
|
|
final rows = await local.query('ad_free_entitlement');
|
|
expect(rows, isEmpty);
|
|
});
|
|
});
|
|
}
|
|
|
|
Future<Database> _openFreshDb(String path) async {
|
|
final db = await databaseFactory.openDatabase(path);
|
|
await db.execute(createVehiclesTableSql);
|
|
await db.execute(createFuelEntriesTableSql);
|
|
await db.execute(createFuelEntriesIndexSql);
|
|
await db.execute(createAdFreeEntitlementTableSql);
|
|
return db;
|
|
}
|
|
|
|
Map<String, Object?> _vehicleRow({
|
|
required String id,
|
|
required String vin,
|
|
String? nickname = 'Test Vehicle',
|
|
int updatedAt = 0,
|
|
int? deletedAt,
|
|
int dirty = 0,
|
|
}) =>
|
|
{
|
|
'id': id,
|
|
'vin': vin,
|
|
'nickname': nickname,
|
|
'updated_at': updatedAt,
|
|
'deleted_at': deletedAt,
|
|
'dirty': dirty,
|
|
};
|
|
|
|
Map<String, Object?> _entitlementRow({int? adFreeUntil, int updatedAt = 0}) => {
|
|
'id': 1,
|
|
'ad_free_until': adFreeUntil,
|
|
'updated_at': updatedAt,
|
|
};
|
|
|
|
Map<String, Object?> _fuelEntryRow({
|
|
required String id,
|
|
required String vehicleId,
|
|
int updatedAt = 0,
|
|
int? deletedAt,
|
|
int dirty = 0,
|
|
}) =>
|
|
{
|
|
'id': id,
|
|
'vehicle_id': vehicleId,
|
|
'date': updatedAt,
|
|
'gallons': 10.0,
|
|
'price_per_gallon': 3.5,
|
|
'total_cost': 35.0,
|
|
'receipt_image_path': null,
|
|
'receipt_drive_file_id': null,
|
|
'updated_at': updatedAt,
|
|
'deleted_at': deletedAt,
|
|
'dirty': dirty,
|
|
};
|