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] / DriveSyncService._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', updatedAt: 1000)); await merge(); final rows = await local.query('vehicles'); expect(rows, hasLength(1)); expect(rows.first['id'], 'v1'); expect(rows.first['dirty'], 0); }); test('leaves a local-only dirty vehicle untouched', () async { await local.insert('vehicles', _vehicleRow(id: 'v1', 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', make: 'OldMake', updatedAt: 1000, dirty: 1)); await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'NewMake', updatedAt: 2000)); await merge(); final rows = await local.query('vehicles'); expect(rows, hasLength(1)); expect(rows.first['make'], 'NewMake'); expect(rows.first['dirty'], 0); }); test('local wins on a tie or when strictly newer', () async { await local.insert( 'vehicles', _vehicleRow(id: 'v1', make: 'MineNewer', updatedAt: 2000, dirty: 1)); await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'TheirsOlder', updatedAt: 1000)); await merge(); final rows = await local.query('vehicles'); expect(rows, hasLength(1)); expect(rows.first['make'], 'MineNewer'); expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten'); }); }); 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, String make = 'Ford', int updatedAt = 0, int? deletedAt, int dirty = 0, }) => { 'id': id, 'make': make, 'model': 'F-150', 'color': 'Red', 'license_plate': 'ABC123', '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, };