MO-Fuel-Tax-Back/test/db_merge_test.dart

202 lines
6.5 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);
} 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);
});
});
}
Future<Database> _openFreshDb(String path) async {
final db = await databaseFactory.openDatabase(path);
await db.execute(createVehiclesTableSql);
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
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?> _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,
};