import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:fuel_tax_tracker/models/fuel_entry.dart'; import 'package:fuel_tax_tracker/models/vehicle.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'; /// Both purge paths reuse the same soft-delete tombstones as a normal /// per-row delete (see [DatabaseService.softDeleteVehicle]) rather than a /// real SQL `DELETE`, specifically so the deletion is something a cloud /// sync can push to other devices instead of the rows just getting /// silently re-imported from the remote copy on the next sync. void main() { late Directory tempDir; late DatabaseService database; setUpAll(() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }); setUp(() async { tempDir = await Directory.systemTemp.createTemp('purge_data_test_'); PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); database = DatabaseService(); await database.init(); }); tearDown(() async { await database.rawDb.close(); await tempDir.delete(recursive: true); }); Vehicle vehicle({required String id, required String vin}) => Vehicle( id: id, vin: vin, updatedAt: DateTime.utc(2026, 1, 1), ); FuelEntry entry({ required String id, required String vehicleId, required DateTime date, String? receiptImagePath, }) => FuelEntry( id: id, vehicleId: vehicleId, date: date, gallons: 10, pricePerGallon: 3.5, totalCost: 35, updatedAt: DateTime.utc(2026, 1, 1), receiptImagePath: receiptImagePath, ); group('purgeAllData', () { test('soft-deletes every active vehicle and fuel entry, marking them dirty', () async { await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); await database.saveVehicle(vehicle(id: 'v2', vin: 'VIN2')); await database.saveFuelEntry(entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 1))); await database.saveFuelEntry(entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 4, 1))); await database.purgeAllData(DateTime.utc(2026, 8, 13)); expect(await database.getVehicles(), isEmpty); expect(await database.getFuelEntries(), isEmpty); final vehicleRows = await database.rawDb.query('vehicles'); final entryRows = await database.rawDb.query('fuel_entries'); expect(vehicleRows, hasLength(2), reason: 'tombstoned, not hard-deleted'); expect(entryRows, hasLength(2)); for (final row in [...vehicleRows, ...entryRows]) { expect(row['deleted_at'], isNotNull); expect(row['dirty'], 1); } }); test('empties the local receipts folder, including untracked files', () async { final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); final storedPath = await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 1)); // Simulate an orphaned file the DB never referenced. final strayFile = File(p.join(database.receiptsDirectory.path, 'stray.jpg')) ..writeAsBytesSync([9]); await database.purgeAllData(DateTime.utc(2026, 8, 13)); expect(await File(storedPath).exists(), isFalse); expect(await strayFile.exists(), isFalse); expect(await database.receiptsDirectory.exists(), isTrue, reason: 'recreated empty, ready for future use'); }); test('leaves already-deleted rows alone', () async { await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); await database.softDeleteVehicle('v1', DateTime.utc(2026, 1, 2)); await database.purgeAllData(DateTime.utc(2026, 8, 13)); final rows = await database.rawDb.query('vehicles', where: 'id = ?', whereArgs: ['v1']); expect(rows.single['deleted_at'], DateTime.utc(2026, 1, 2).millisecondsSinceEpoch); }); }); group('purgeFuelEntriesInRange', () { test('soft-deletes only entries dated within the inclusive range', () async { await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); await database.saveFuelEntry( entry(id: 'before', vehicleId: 'v1', date: DateTime(2026, 2, 28)), ); await database.saveFuelEntry( entry(id: 'start-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 1)), ); await database.saveFuelEntry( entry(id: 'inside', vehicleId: 'v1', date: DateTime(2026, 3, 15)), ); await database.saveFuelEntry( entry(id: 'end-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 31)), ); await database.saveFuelEntry( entry(id: 'after', vehicleId: 'v1', date: DateTime(2026, 4, 1)), ); await database.purgeFuelEntriesInRange( DateTime(2026, 3, 1), DateTime(2026, 3, 31), DateTime.utc(2026, 8, 13), ); final remaining = await database.getFuelEntries(); expect(remaining.map((e) => e.id).toSet(), {'before', 'after'}); expect(await database.getVehicles(), hasLength(1), reason: 'vehicles are untouched'); }); test('returns local receipt paths for the caller to delete, without touching untracked files', () async { await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); final storedPath = await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 15)); await database.saveFuelEntry( entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 15), receiptImagePath: storedPath), ); final paths = await database.purgeFuelEntriesInRange( DateTime(2026, 3, 1), DateTime(2026, 3, 31), DateTime.utc(2026, 8, 13), ); expect(paths, [storedPath]); // purgeFuelEntriesInRange only reports paths — deleting the files // themselves is the caller's job (AppState), mirroring // softDeleteFuelEntriesForVehicle. expect(await File(storedPath).exists(), isTrue); }); }); } class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { final String tempPath; _FakePathProviderPlatform(this.tempPath); @override Future getApplicationDocumentsPath() async => tempPath; @override Future getTemporaryPath() async => tempPath; }