import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:fuel_tax_tracker/services/database_service.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; /// Confirms the version 3 → 4 migration (VIN becomes editable; a hidden /// `id` takes over as vehicles' primary key/merge key) preserves existing /// local data rather than dropping it, unlike the earlier VIN-as-primary-key /// migration which was a from-scratch rebuild. Builds a real on-disk /// version-3 database by hand (the old schema, pre-dating this change), then /// runs [DatabaseService.init] against it and checks what comes out. void main() { late Directory tempDir; setUpAll(() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }); setUp(() async { tempDir = await Directory.systemTemp.createTemp('vehicle_id_migration_test_'); PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); }); tearDown(() async { await tempDir.delete(recursive: true); }); test('migrates a version-3 database, preserving vehicles and fuel entries', () async { final dbDir = Directory(p.join(tempDir.path, 'FuelTaxTracker')); await dbDir.create(recursive: true); final dbPath = p.join(dbDir.path, dbFileName); final oldDb = await databaseFactory.openDatabase( dbPath, options: OpenDatabaseOptions( version: 3, onCreate: (db, version) async { await db.execute(''' CREATE TABLE vehicles ( vin TEXT PRIMARY KEY, nickname TEXT, updated_at INTEGER NOT NULL, deleted_at INTEGER, dirty INTEGER NOT NULL DEFAULT 1 ) '''); await db.execute(''' CREATE TABLE fuel_entries ( id TEXT PRIMARY KEY, vehicle_vin TEXT NOT NULL, date INTEGER NOT NULL, gallons REAL NOT NULL, price_per_gallon REAL NOT NULL, total_cost REAL NOT NULL, receipt_image_path TEXT, receipt_drive_file_id TEXT, updated_at INTEGER NOT NULL, deleted_at INTEGER, dirty INTEGER NOT NULL DEFAULT 1 ) '''); }, ), ); await oldDb.insert('vehicles', { 'vin': 'VIN1', 'nickname': "Mom's Car", 'updated_at': 1000, 'deleted_at': null, 'dirty': 0, }); await oldDb.insert('vehicles', { 'vin': 'VIN2', 'nickname': null, 'updated_at': 1500, 'deleted_at': 2000, // a soft-deleted vehicle, should still migrate 'dirty': 0, }); await oldDb.insert('fuel_entries', { 'id': 'f1', 'vehicle_vin': 'VIN1', 'date': 1200, 'gallons': 12.5, 'price_per_gallon': 3.2, 'total_cost': 40.0, 'receipt_image_path': '/local/receipt.jpg', 'receipt_drive_file_id': null, 'updated_at': 1200, 'deleted_at': null, 'dirty': 0, }); await oldDb.close(); final databaseService = DatabaseService(); await databaseService.init(); addTearDown(() => databaseService.rawDb.close()); final vehicles = await databaseService.getVehicles(); expect(vehicles, hasLength(1), reason: 'the soft-deleted vehicle should not show as active'); expect(vehicles.first.vin, 'VIN1'); expect(vehicles.first.nickname, "Mom's Car"); expect(vehicles.first.id, isNotEmpty); final allVehicleRows = await databaseService.rawDb.query('vehicles'); expect(allVehicleRows, hasLength(2), reason: 'the soft-deleted vehicle should still exist'); final ids = {for (final row in allVehicleRows) row['vin']: row['id'] as String}; expect(ids['VIN1'], isNotEmpty); expect(ids['VIN2'], isNotEmpty); expect(ids['VIN1'], isNot(ids['VIN2']), reason: 'each vehicle gets its own generated id'); for (final row in allVehicleRows) { expect(row['dirty'], 1, reason: 'migrated rows must be re-pushed under the new schema'); } final fuelEntries = await databaseService.getFuelEntries(); expect(fuelEntries, hasLength(1)); expect(fuelEntries.first.vehicleId, ids['VIN1'], reason: "the fuel entry's foreign key should now point at VIN1's new id"); expect(fuelEntries.first.gallons, 12.5); expect(fuelEntries.first.receiptImagePath, '/local/receipt.jpg'); }); } class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { final String tempPath; _FakePathProviderPlatform(this.tempPath); @override Future getApplicationDocumentsPath() async => tempPath; @override Future getTemporaryPath() async => tempPath; }