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

174 lines
6.5 KiB
Dart

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<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}