Added sqlite
This commit is contained in:
parent
73a7c93e64
commit
f01186df79
14 changed files with 784 additions and 382 deletions
172
test/db_merge_test.dart
Normal file
172
test/db_merge_test.dart
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
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<void> 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<Database> _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<String, Object?> _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<String, Object?> _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,
|
||||
};
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/merge_utils.dart';
|
||||
|
||||
class _Record {
|
||||
final String id;
|
||||
final String value;
|
||||
final DateTime updatedAt;
|
||||
|
||||
_Record(this.id, this.value, this.updatedAt);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('mergeById', () {
|
||||
test('unions records that only exist on one side', () {
|
||||
final remote = [_Record('a', 'remote-a', DateTime(2024, 1, 1))];
|
||||
final local = [_Record('b', 'local-b', DateTime(2024, 1, 1))];
|
||||
|
||||
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
|
||||
|
||||
expect(result.map((r) => r.id), containsAll(['a', 'b']));
|
||||
expect(result.length, 2);
|
||||
});
|
||||
|
||||
test('keeps the newer updatedAt when a record exists on both sides', () {
|
||||
final remote = [_Record('a', 'remote-old', DateTime(2024, 1, 1))];
|
||||
final local = [_Record('a', 'local-new', DateTime(2024, 6, 1))];
|
||||
|
||||
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
|
||||
|
||||
expect(result.length, 1);
|
||||
expect(result.first.value, 'local-new');
|
||||
});
|
||||
|
||||
test('keeps the remote copy when it is newer than local', () {
|
||||
final remote = [_Record('a', 'remote-new', DateTime(2024, 6, 1))];
|
||||
final local = [_Record('a', 'local-old', DateTime(2024, 1, 1))];
|
||||
|
||||
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
|
||||
|
||||
expect(result.length, 1);
|
||||
expect(result.first.value, 'remote-new');
|
||||
});
|
||||
|
||||
test('local wins ties', () {
|
||||
final sameTime = DateTime(2024, 1, 1);
|
||||
final remote = [_Record('a', 'remote', sameTime)];
|
||||
final local = [_Record('a', 'local', sameTime)];
|
||||
|
||||
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
|
||||
|
||||
expect(result.first.value, 'local');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue