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 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 _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 _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 _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, };