Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
196
test/cloud_sync_service_test.dart
Normal file
196
test/cloud_sync_service_test.dart
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud_sync_service.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 [CloudSyncService]'s orchestration (lock → check remote →
|
||||
/// upload → release) works against *any* [CloudStorageProvider], using a
|
||||
/// fake one — coverage for the provider-agnostic logic itself, independent
|
||||
/// of which real backend (Google Drive, Dropbox, OneDrive) it's wired to.
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late DatabaseService databaseService;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('cloud_sync_service_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
databaseService = DatabaseService();
|
||||
await databaseService.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await databaseService.rawDb.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('syncNow acquires a lock, uploads the data file, and releases the lock', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
syncService.configure('app-folder-id');
|
||||
|
||||
final result = await syncService.syncNow();
|
||||
|
||||
expect(result.ranSync, isTrue);
|
||||
expect(session.createdLockNames, hasLength(1));
|
||||
expect(session.createdLockNames.first, startsWith('tester@example.com-'));
|
||||
expect(session.uploadedFileNames, contains(dataFileName));
|
||||
expect(session.deletedFileIds, ['lock-id'], reason: 'the lock must be released afterward');
|
||||
});
|
||||
|
||||
test('syncNow uploads a pending receipt into Receipts/<vin>/<yyyy.mm>', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
syncService.configure('app-folder-id');
|
||||
|
||||
final db = databaseService.rawDb;
|
||||
await db.insert('vehicles', {
|
||||
'id': 'vehicle-1',
|
||||
'vin': '1FMPU18L1TLB51349',
|
||||
'nickname': null,
|
||||
'updated_at': 0,
|
||||
'deleted_at': null,
|
||||
'dirty': 0,
|
||||
});
|
||||
final receiptFile = File(p.join(tempDir.path, 'receipt.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
await db.insert('fuel_entries', {
|
||||
'id': 'entry-1',
|
||||
'vehicle_id': 'vehicle-1',
|
||||
// Mid-month/mid-year so DateTime.fromMillisecondsSinceEpoch's local
|
||||
// interpretation can't drift into a different month depending on the
|
||||
// machine's timezone (unlike epoch 0, which can land in Dec 1969).
|
||||
'date': DateTime(2026, 3, 15).millisecondsSinceEpoch,
|
||||
'gallons': 10.0,
|
||||
'price_per_gallon': 3.5,
|
||||
'total_cost': 35.0,
|
||||
'receipt_image_path': receiptFile.path,
|
||||
'receipt_drive_file_id': null,
|
||||
'updated_at': 0,
|
||||
'deleted_at': null,
|
||||
'dirty': 1,
|
||||
});
|
||||
|
||||
final result = await syncService.syncNow(keepLocalReceiptCopies: true);
|
||||
|
||||
expect(result.ranSync, isTrue);
|
||||
expect(session.uploadedFileFolders['entry-1.jpg'],
|
||||
'app-folder-id/$receiptsFolderName/1FMPU18L1TLB51349/2026.03');
|
||||
});
|
||||
|
||||
test('syncNow is a no-op when not configured with a folder yet', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
|
||||
final result = await syncService.syncNow();
|
||||
|
||||
expect(result.ranSync, isFalse);
|
||||
expect(session.createdLockNames, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
|
||||
class _FakeSession implements CloudStorageSession {
|
||||
final List<String> createdLockNames = [];
|
||||
final List<String> deletedFileIds = [];
|
||||
final List<String> uploadedFileNames = [];
|
||||
final Map<String, String> uploadedFileFolders = {};
|
||||
CloudFileInfo? existingDataFile;
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async =>
|
||||
'$parentId/$name';
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
return name == dataFileName ? existingDataFile : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async => <int>[];
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
uploadedFileNames.add(name);
|
||||
uploadedFileFolders[name] = folderId;
|
||||
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async => deletedFileIds.add(fileId);
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
createdLockNames.add(name);
|
||||
return 'lock-id';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async => [];
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
class _FakeProvider implements CloudStorageProvider {
|
||||
final _FakeSession session;
|
||||
_FakeProvider(this.session);
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.googleDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'Fake Provider';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => true;
|
||||
|
||||
@override
|
||||
String? get accountLabel => 'tester@example.com';
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async => true;
|
||||
|
||||
@override
|
||||
Future<String> signIn() async => accountLabel!;
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() => session;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ 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
|
||||
/// (see [db_schema.dart] / CloudSyncService._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() {
|
||||
|
|
@ -43,18 +43,19 @@ void main() {
|
|||
|
||||
group('vehicle merge', () {
|
||||
test('pulls in a vehicle that only exists on remote', () async {
|
||||
await remote.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000));
|
||||
await remote.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['id'], 'v1');
|
||||
expect(rows.first['vin'], 'VIN1');
|
||||
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 local.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000, dirty: 1));
|
||||
|
||||
await merge();
|
||||
|
||||
|
|
@ -64,30 +65,60 @@ void main() {
|
|||
});
|
||||
|
||||
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 local.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'OldNickname', updatedAt: 1000, dirty: 1));
|
||||
await remote.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'NewNickname', updatedAt: 2000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['make'], 'NewMake');
|
||||
expect(rows.first['nickname'], 'NewNickname');
|
||||
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 local.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'MineNewer', updatedAt: 2000, dirty: 1));
|
||||
await remote.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'TheirsOlder', updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['make'], 'MineNewer');
|
||||
expect(rows.first['nickname'], 'MineNewer');
|
||||
expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten');
|
||||
});
|
||||
|
||||
test('a null nickname merges in fine (nickname is optional)', () async {
|
||||
await remote.insert(
|
||||
'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', nickname: null, updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['nickname'], isNull);
|
||||
});
|
||||
|
||||
test('the same vehicle edited on two devices merges by id, not vin', () async {
|
||||
// VIN is user-editable, so it can't be the merge key — this is the
|
||||
// scenario that motivated switching the merge key to a hidden id:
|
||||
// the local device renamed the VIN (a legitimate edit) while the
|
||||
// remote copy still has the old VIN and an older updated_at.
|
||||
await local.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1-CORRECTED', nickname: 'Mine', updatedAt: 2000, dirty: 1));
|
||||
await remote.insert(
|
||||
'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1-TYPO', nickname: 'Mine', updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['vin'], 'VIN1-CORRECTED', reason: 'local was newer, so its VIN edit wins');
|
||||
});
|
||||
});
|
||||
|
||||
group('fuel entry merge', () {
|
||||
|
|
@ -134,17 +165,16 @@ Future<Database> _openFreshDb(String path) async {
|
|||
|
||||
Map<String, Object?> _vehicleRow({
|
||||
required String id,
|
||||
String make = 'Ford',
|
||||
required String vin,
|
||||
String? nickname = 'Test Vehicle',
|
||||
int updatedAt = 0,
|
||||
int? deletedAt,
|
||||
int dirty = 0,
|
||||
}) =>
|
||||
{
|
||||
'id': id,
|
||||
'make': make,
|
||||
'model': 'F-150',
|
||||
'color': 'Red',
|
||||
'license_plate': 'ABC123',
|
||||
'vin': vin,
|
||||
'nickname': nickname,
|
||||
'updated_at': updatedAt,
|
||||
'deleted_at': deletedAt,
|
||||
'dirty': dirty,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/drive_service.dart' show DriveLockFile;
|
||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart' show CloudLockFile;
|
||||
import 'package:fuel_tax_tracker/services/lock_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -16,7 +16,7 @@ void main() {
|
|||
return 'id';
|
||||
},
|
||||
listLocks: () async =>
|
||||
[DriveLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)],
|
||||
[CloudLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)],
|
||||
deleteLock: (_) async {},
|
||||
delay: (_) async {},
|
||||
);
|
||||
|
|
@ -35,7 +35,7 @@ void main() {
|
|||
createLock: (_) async => 'my-lock-id',
|
||||
listLocks: () async {
|
||||
listCallCount++;
|
||||
return [DriveLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)];
|
||||
return [CloudLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)];
|
||||
},
|
||||
deleteLock: (_) async {},
|
||||
delay: (d) async => delayCalls.add(d),
|
||||
|
|
@ -55,8 +55,8 @@ void main() {
|
|||
nowUtc: () => now,
|
||||
createLock: (_) async => 'mine',
|
||||
listLocks: () async => [
|
||||
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
DriveLockFile(
|
||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
CloudLockFile(
|
||||
id: 'newer', username: 'other', createdAtUtc: now.add(const Duration(seconds: 5))),
|
||||
],
|
||||
deleteLock: (_) async {},
|
||||
|
|
@ -81,14 +81,14 @@ void main() {
|
|||
listCallCount++;
|
||||
if (listCallCount == 1) {
|
||||
return [
|
||||
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
DriveLockFile(
|
||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
CloudLockFile(
|
||||
id: 'other',
|
||||
username: 'other',
|
||||
createdAtUtc: now.subtract(const Duration(seconds: 5))),
|
||||
];
|
||||
}
|
||||
return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
},
|
||||
deleteLock: (id) async => deleteCalls.add(id),
|
||||
delay: (d) async => delayCalls.add(d),
|
||||
|
|
@ -114,14 +114,14 @@ void main() {
|
|||
listCallCount++;
|
||||
if (listCallCount == 1) {
|
||||
return [
|
||||
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
DriveLockFile(
|
||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
CloudLockFile(
|
||||
id: 'stale',
|
||||
username: 'ghost',
|
||||
createdAtUtc: now.subtract(const Duration(minutes: 15))),
|
||||
];
|
||||
}
|
||||
return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
},
|
||||
deleteLock: (id) async => deleteCalls.add(id),
|
||||
delay: (_) async {},
|
||||
|
|
|
|||
98
test/pending_receipt_upload_test.dart
Normal file
98
test/pending_receipt_upload_test.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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';
|
||||
|
||||
/// Regression coverage for a real bug: the "needs upload" query used to
|
||||
/// match on `receipt_image_path IS NOT NULL` alone, which would have kept
|
||||
/// re-uploading the same photo as a duplicate Drive file on every sync
|
||||
/// once a "keep photos on this phone" option let an already-uploaded row
|
||||
/// keep its local path. See [pendingReceiptUploadWhereClause].
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late Database db;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('pending_receipt_test_');
|
||||
db = await databaseFactory.openDatabase(p.join(tempDir.path, 'test.db'));
|
||||
await db.execute(createFuelEntriesTableSql);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> insertEntry(
|
||||
String id, {
|
||||
String? receiptImagePath,
|
||||
String? receiptDriveFileId,
|
||||
int dirty = 1,
|
||||
int? deletedAt,
|
||||
}) {
|
||||
return db.insert('fuel_entries', {
|
||||
'id': id,
|
||||
'vehicle_id': 'v1',
|
||||
'date': 0,
|
||||
'gallons': 10.0,
|
||||
'price_per_gallon': 3.5,
|
||||
'total_cost': 35.0,
|
||||
'receipt_image_path': receiptImagePath,
|
||||
'receipt_drive_file_id': receiptDriveFileId,
|
||||
'updated_at': 0,
|
||||
'deleted_at': deletedAt,
|
||||
'dirty': dirty,
|
||||
});
|
||||
}
|
||||
|
||||
test('matches a row with a local photo not yet uploaded', () async {
|
||||
await insertEntry('needs-upload', receiptImagePath: '/local/a.jpg');
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows.map((r) => r['id']), ['needs-upload']);
|
||||
});
|
||||
|
||||
test('excludes a row already uploaded, even if the local copy was kept', () async {
|
||||
await insertEntry(
|
||||
'kept-after-upload',
|
||||
receiptImagePath: '/local/a.jpg',
|
||||
receiptDriveFileId: 'drive-file-1',
|
||||
);
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes a row with no local photo at all', () async {
|
||||
await insertEntry('no-receipt');
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes a soft-deleted row even if it has a pending local photo', () async {
|
||||
await insertEntry('deleted', receiptImagePath: '/local/a.jpg', deletedAt: 123);
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes a clean (already-synced) row', () async {
|
||||
await insertEntry('clean', receiptImagePath: '/local/a.jpg', dirty: 0);
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -21,6 +21,37 @@ void main() {
|
|||
expect(result.totalCost, 44.44);
|
||||
});
|
||||
|
||||
test('parses a tabular receipt where labels and values sit on separate lines', () {
|
||||
// Mirrors a real Casey's receipt: "Pump / Gallons / Price" is a
|
||||
// header row, with the actual values on the next line, and the
|
||||
// total is phrased "Total Sale" rather than a bare "Total".
|
||||
const receipt = '''
|
||||
Casey's
|
||||
Store #4021
|
||||
420 W. 6TH STREET
|
||||
KEARNEY, MO 64060
|
||||
Date 08/08/2026
|
||||
Time 02:21
|
||||
VI
|
||||
########8533
|
||||
Pump Gallons Price
|
||||
07 32.115 \$ 3.759
|
||||
Product Amount
|
||||
87E10 \$ 120.72
|
||||
Total Sale \$ 120.72
|
||||
Auth #
|
||||
Visa
|
||||
Seq # 008299
|
||||
98
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 32.115);
|
||||
expect(result.pricePerGallon, 3.759);
|
||||
expect(result.totalCost, 120.72);
|
||||
});
|
||||
|
||||
test('does not confuse SUBTOTAL with TOTAL', () {
|
||||
const receipt = '''
|
||||
SUBTOTAL 10.00
|
||||
|
|
@ -67,6 +98,386 @@ void main() {
|
|||
expect(result.gallons, isNull);
|
||||
expect(result.pricePerGallon, isNull);
|
||||
expect(result.totalCost, isNull);
|
||||
expect(result.date, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ReceiptParser state parsing', () {
|
||||
test('detects a state abbreviation directly before a ZIP code', () {
|
||||
const receipt = '''
|
||||
Casey's
|
||||
420 W. 6TH STREET
|
||||
KEARNEY, MO 64060
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, 'MO');
|
||||
});
|
||||
|
||||
test('detects a non-Missouri state the same way', () {
|
||||
const receipt = '''
|
||||
Swift Stop #3
|
||||
1809 Hobbs Hwy
|
||||
Seminole TX 79360
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, 'TX');
|
||||
});
|
||||
|
||||
test('falls back to a state right after a comma when no ZIP is nearby', () {
|
||||
const receipt = '''
|
||||
Buc-ee's
|
||||
6988 Buc-ee's Blvd
|
||||
Leeds, AL
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, 'AL');
|
||||
});
|
||||
|
||||
test('does not mistake "VI" (Visa) for the Virgin Islands', () {
|
||||
const receipt = '''
|
||||
Casey's
|
||||
VI
|
||||
########8533
|
||||
Pump Gallons Price
|
||||
07 32.115 \$ 3.759
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, isNull);
|
||||
});
|
||||
|
||||
test('does not mistake the word "In" for Indiana', () {
|
||||
const receipt = '''
|
||||
Gallons 10.000
|
||||
Price/Gal \$3.399
|
||||
Total Fuel \$33.99
|
||||
All Taxes Included
|
||||
In Fuel Price.
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, isNull);
|
||||
});
|
||||
|
||||
test('returns null when no address-like state pattern is present', () {
|
||||
expect(ReceiptParser.parse('THANK YOU FOR YOUR PURCHASE').state, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ReceiptParser date parsing', () {
|
||||
test('parses "Date: M/D/YYYY" + "Time: H:MM:SS AM/PM" on separate lines', () {
|
||||
const receipt = '''
|
||||
Date: 8/22/2024
|
||||
Time: 10:11:00 AM
|
||||
Gallons 10.000
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.date, DateTime(2024, 8, 22, 10, 11));
|
||||
});
|
||||
|
||||
test('parses "DATE M/D/YY H:MM" on the same line', () {
|
||||
const receipt = '''
|
||||
DATE 3/26/22 18:12
|
||||
PUMP# 06
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.date, DateTime(2022, 3, 26, 18, 12));
|
||||
});
|
||||
|
||||
test('parses a 2-digit year and a no-space AM/PM time', () {
|
||||
const receipt = '''
|
||||
Date 01/23/20
|
||||
Time 02:11PM
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.date, DateTime(2020, 1, 23, 14, 11));
|
||||
});
|
||||
|
||||
test('12 AM and 12 PM convert correctly (midnight/noon edge case)', () {
|
||||
expect(ReceiptParser.parse('Date 1/1/24 12:00 AM').date, DateTime(2024, 1, 1, 0, 0));
|
||||
expect(ReceiptParser.parse('Date 1/1/24 12:30 PM').date, DateTime(2024, 1, 1, 12, 30));
|
||||
});
|
||||
|
||||
test('falls back to midnight when a date is found but no time is nearby', () {
|
||||
// Comfortably more than the 40-character "nearby" search window
|
||||
// between the date and the unrelated text before a time appears
|
||||
// much later, regardless of how this string literal is indented.
|
||||
final filler = 'x' * 100;
|
||||
final receipt = 'Date 12/14/16\n$filler\nTime 5:11 PM';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
// Too far from the date to count as "nearby", so this should be
|
||||
// date-only at midnight — still better than no date at all, and the
|
||||
// confirm screen lets the user fix the time manually either way.
|
||||
expect(result.date, isNotNull);
|
||||
expect(result.date!.year, 2016);
|
||||
expect(result.date!.month, 12);
|
||||
expect(result.date!.day, 14);
|
||||
expect(result.date!.hour, 0);
|
||||
expect(result.date!.minute, 0);
|
||||
});
|
||||
|
||||
test('returns null when no date-like pattern is present', () {
|
||||
final result = ReceiptParser.parse('GALLONS 10.000\nPPG 3.500');
|
||||
|
||||
expect(result.date, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// Transcribed from a batch of real photographed receipts across different
|
||||
// gas station chains, to catch label/layout variations the handful of
|
||||
// hand-written cases above don't happen to cover.
|
||||
group('ReceiptParser against real receipt layouts', () {
|
||||
test('Break Time — labels adjacent to values on the same line', () {
|
||||
const receipt = '''
|
||||
Date: 8/22/2024
|
||||
Time: 10:11:00 AM
|
||||
OXY87
|
||||
Pump Number 22
|
||||
Gallons 10.000
|
||||
Price/Gal \$3.399
|
||||
Total Fuel \$33.99
|
||||
Total Sale \$33.99
|
||||
Visa \$33.99
|
||||
All Taxes Included
|
||||
In Fuel Price.
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 10.000);
|
||||
expect(result.pricePerGallon, 3.399);
|
||||
expect(result.totalCost, 33.99);
|
||||
});
|
||||
|
||||
test('Break Time — tabular Pump/Gallons/Price header row', () {
|
||||
const receipt = '''
|
||||
Member# 111795637651
|
||||
Invoice# 11733
|
||||
Date: 03/23/22
|
||||
Time: 10:05
|
||||
Auth# 020413
|
||||
VI Acct #
|
||||
************0344
|
||||
Pump Gallons Price
|
||||
14 5.116 \$ 5.499
|
||||
Product Amount
|
||||
Regular \$ 28.13
|
||||
Total Sale \$ 28.13
|
||||
SALE- Contactless
|
||||
Approved
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 5.116);
|
||||
expect(result.pricePerGallon, 5.499);
|
||||
expect(result.totalCost, 28.13);
|
||||
});
|
||||
|
||||
test('Buc-ee\'s — abbreviated "PRICE/G" label', () {
|
||||
const receipt = '''
|
||||
BUC-EE'S
|
||||
6988 Buc-ee's Blvd
|
||||
Leeds AL
|
||||
PUMP No. 63
|
||||
GALLONS 9.999
|
||||
PRICE/G \$2.499
|
||||
TOTAL FUEL \$24.99
|
||||
Regular
|
||||
TOTAL SALE \$24.99
|
||||
Visa \$24.99
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 9.999);
|
||||
expect(result.pricePerGallon, 2.499);
|
||||
expect(result.totalCost, 24.99);
|
||||
});
|
||||
|
||||
test('Costco — tabular header row, "Total Sale" phrasing', () {
|
||||
const receipt = '''
|
||||
Costco #01448
|
||||
1524 Beasie RD
|
||||
Murfreesboro TN
|
||||
Date: 01/03/24
|
||||
Time: 19:31
|
||||
Pump Gallons Price
|
||||
11 12.598 \$ 2.399
|
||||
Product Amount
|
||||
Regular \$ 30.22
|
||||
Total Sale \$ 30.22
|
||||
SALE- Contactless
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 12.598);
|
||||
expect(result.pricePerGallon, 2.399);
|
||||
expect(result.totalCost, 30.22);
|
||||
});
|
||||
|
||||
test('Murphy USA — "QTY(GAL)" label instead of "GALLONS"', () {
|
||||
const receipt = '''
|
||||
PUMP: 8
|
||||
PROD: UNLEAD
|
||||
PRICE/GAL: \$1.799
|
||||
NET/GAL: \$1.799
|
||||
QTY(GAL): 13.164
|
||||
FUEL TOTAL: \$23.68
|
||||
NET TOTAL: \$23.68
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 13.164);
|
||||
expect(result.pricePerGallon, 1.799);
|
||||
expect(result.totalCost, 23.68);
|
||||
});
|
||||
|
||||
test('Phillips 66 — "PRICE/G" label and "FUEL SALE" as the total', () {
|
||||
const receipt = '''
|
||||
WELCOME
|
||||
LAWNSIDE PHILLIPS 66
|
||||
355 Warrington Ave
|
||||
Lawnside NJ 08045
|
||||
DATE 3/26/22 18:12
|
||||
TRAN# 060757
|
||||
PUMP# 06
|
||||
SERVICE LEVEL: FULL
|
||||
PRODUCT: REGULAR
|
||||
GALLONS 3.642
|
||||
PRICE/G \$4.119
|
||||
FUEL SALE \$15.00
|
||||
CREDIT \$15.00
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 3.642);
|
||||
expect(result.pricePerGallon, 4.119);
|
||||
expect(result.totalCost, 15.00);
|
||||
});
|
||||
|
||||
test('Phillips 66 — second "FUEL SALE" example', () {
|
||||
const receipt = '''
|
||||
WELCOME
|
||||
SWIFT STOP #3
|
||||
1809 HOBBS HWY
|
||||
SEMINOLE TX 79360
|
||||
DATE 5/9/23 11:16
|
||||
TRAN#9103742
|
||||
PUMP# 10
|
||||
SERVICE LEVEL: SELF
|
||||
PRODUCT: DIESEL 2
|
||||
GALLONS: 29.854
|
||||
PRICE/G: \$3.499
|
||||
FUEL SALE \$181.66
|
||||
CREDIT \$181.66
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 29.854);
|
||||
expect(result.pricePerGallon, 3.499);
|
||||
expect(result.totalCost, 181.66);
|
||||
});
|
||||
|
||||
test('Phillips 66 / Murphy-style — "QTY(GAL)" plus "FUEL TOTAL"', () {
|
||||
const receipt = '''
|
||||
Verified by PIN
|
||||
PIN USED
|
||||
PUMP: 5
|
||||
PROD: UNLEAD
|
||||
PRICE/GAL: \$3.959
|
||||
NET/GAL: \$3.959
|
||||
QTY(GAL): 11.068
|
||||
FUEL TOTAL: \$43.82
|
||||
NET TOTAL: \$43.82
|
||||
GET REWARDED!
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 11.068);
|
||||
expect(result.pricePerGallon, 3.959);
|
||||
expect(result.totalCost, 43.82);
|
||||
});
|
||||
|
||||
test('QuikTrip — tabular header row, total not printed on receipt (derived)', () {
|
||||
const receipt = '''
|
||||
QUIKTRIP #01012
|
||||
383 George Liles PWK
|
||||
Concord, NC
|
||||
Invoice # 0000000
|
||||
Date 01/23/20
|
||||
Time 02:11PM
|
||||
Auth # 03581I
|
||||
Acct #
|
||||
************1821
|
||||
Pump Gallons Price
|
||||
14 10.496 \$2.359
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 10.496);
|
||||
expect(result.pricePerGallon, 2.359);
|
||||
// Not printed within the visible receipt text — derived from
|
||||
// gallons * price rather than found via a label.
|
||||
expect(result.totalCost, closeTo(10.496 * 2.359, 0.01));
|
||||
});
|
||||
|
||||
test('Sinclair — unit letter attached with no space, and a negative '
|
||||
'per-gallon discount line that must not be mistaken for gallons', () {
|
||||
const receipt = '''
|
||||
PUMP# 3
|
||||
UNLEADED CR 17.364G
|
||||
PRICE/GAL \$2.649
|
||||
DISCOUNTS BEFORE
|
||||
FUELING
|
||||
Debit Di/GAL \$-0.100
|
||||
FUEL TOTAL \$46.00
|
||||
DEBIT \$46.00
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 17.364);
|
||||
expect(result.pricePerGallon, 2.649);
|
||||
expect(result.totalCost, 46.00);
|
||||
});
|
||||
|
||||
test('Sinclair — PPG and GALLONS/FUEL TOTAL both split across header/data rows', () {
|
||||
const receipt = '''
|
||||
SINCLAIR
|
||||
5905 NW 72ND ST
|
||||
KANSAS CITY MO
|
||||
Date 12/14/16
|
||||
Time 5:11 PM
|
||||
Auth 068113
|
||||
VISA
|
||||
Pump Product PPG
|
||||
05 UNLD \$1.999
|
||||
Gallons Fuel Total
|
||||
9.505 \$19.00
|
||||
Thank you
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 9.505);
|
||||
expect(result.pricePerGallon, 1.999);
|
||||
// "Fuel Total" is split from its value by a newline in this layout,
|
||||
// so it isn't label-matched — derived from gallons * price instead,
|
||||
// landing within a cent of the printed $19.00.
|
||||
expect(result.totalCost, closeTo(19.00, 0.01));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
89
test/receipt_storage_test.dart
Normal file
89
test/receipt_storage_test.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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';
|
||||
|
||||
/// Receipts are filed under `receipts/<vin>/<yyyy.mm>/` locally (mirroring
|
||||
/// the `Receipts/<vin>/<yyyy.mm>` layout the cloud side uses — see
|
||||
/// `cloud_sync_service_test.dart`), so browsing either one shows which
|
||||
/// vehicle and month a photo belongs to.
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late DatabaseService database;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('receipt_storage_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
database = DatabaseService();
|
||||
await database.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.rawDb.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('storeReceiptImage files the photo under receipts/<vin>/<yyyy.mm>/', () async {
|
||||
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
|
||||
final storedPath = await database.storeReceiptImage(
|
||||
source,
|
||||
'entry-1',
|
||||
'1FMPU18L1TLB51349',
|
||||
DateTime(2026, 8, 13),
|
||||
);
|
||||
|
||||
expect(
|
||||
p.dirname(storedPath),
|
||||
p.join(database.receiptsDirectory.path, '1FMPU18L1TLB51349', '2026.08'),
|
||||
);
|
||||
expect(p.basename(storedPath), 'entry-1.jpg');
|
||||
expect(await File(storedPath).exists(), isTrue);
|
||||
});
|
||||
|
||||
test('storeReceiptImage sanitizes a VIN with characters unsafe in a path', () async {
|
||||
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
|
||||
final storedPath = await database.storeReceiptImage(
|
||||
source,
|
||||
'entry-2',
|
||||
'../etc/passwd',
|
||||
DateTime(2026, 1, 5),
|
||||
);
|
||||
|
||||
expect(
|
||||
p.dirname(storedPath),
|
||||
p.join(
|
||||
database.receiptsDirectory.path,
|
||||
sanitizedPathSegment('../etc/passwd'),
|
||||
'2026.01',
|
||||
),
|
||||
);
|
||||
expect(p.isWithin(database.receiptsDirectory.path, storedPath), isTrue);
|
||||
});
|
||||
|
||||
test('monthFolderName pads single-digit months', () {
|
||||
expect(monthFolderName(DateTime(2026, 1, 5)), '2026.01');
|
||||
expect(monthFolderName(DateTime(2026, 12, 5)), '2026.12');
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
137
test/vehicle_id_migration_test.dart
Normal file
137
test/vehicle_id_migration_test.dart
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
47
test/vin_parser_test.dart
Normal file
47
test/vin_parser_test.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/vin_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('VinParser', () {
|
||||
test('parses a bare 17-character VIN', () {
|
||||
const text = '''
|
||||
VEHICLE IDENTIFICATION NUMBER
|
||||
1HGCM82633A004352
|
||||
MFD BY HONDA MFG CO
|
||||
''';
|
||||
|
||||
expect(VinParser.parse(text), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('prefers a "VIN:" labeled match over another candidate elsewhere', () {
|
||||
const text = '''
|
||||
PART NO 4F2CZ58899BA12345
|
||||
VIN: 1HGCM82633A004352
|
||||
''';
|
||||
|
||||
expect(VinParser.parse(text), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('normalizes to uppercase', () {
|
||||
expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('does not match a run containing I, O, or Q (never valid in a real VIN)', () {
|
||||
// Same length (17) but contains an "O" partway through, so no
|
||||
// 17-character eligible-charset run exists anywhere in it.
|
||||
expect(VinParser.parse('1HGCM82633AOO4352'), isNull);
|
||||
});
|
||||
|
||||
test('does not match a 16-character (too short) run', () {
|
||||
expect(VinParser.parse('1HGCM82633A00435'), isNull);
|
||||
});
|
||||
|
||||
test('does not match an 18-character (too long) run', () {
|
||||
expect(VinParser.parse('1HGCM82633A0043521'), isNull);
|
||||
});
|
||||
|
||||
test('returns null when nothing matches', () {
|
||||
expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
115
test/webdav_provider_credentials_test.dart
Normal file
115
test/webdav_provider_credentials_test.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart';
|
||||
|
||||
const _multistatusBody = '''<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>''';
|
||||
|
||||
/// Confirms sign-in survives a cold app restart: [WebDavProvider] persists
|
||||
/// verified credentials to secure storage on [signInWithCredentials], and a
|
||||
/// *fresh* instance (an in-memory field reset, standing in for a relaunched
|
||||
/// app) can restore the session from them via [attemptSilentSignIn] — the
|
||||
/// gap flagged in README's "Known limitations" and fixed here.
|
||||
void main() {
|
||||
late HttpServer server;
|
||||
late String serverUrl;
|
||||
const username = 'alice';
|
||||
const password = 'hunter2';
|
||||
|
||||
setUp(() async {
|
||||
FlutterSecureStoragePlatform.instance = _FakeSecureStoragePlatform();
|
||||
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
serverUrl = 'http://127.0.0.1:${server.port}/';
|
||||
unawaited(server.forEach((request) async {
|
||||
final auth = request.headers.value('authorization');
|
||||
final expected = 'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||
if (request.method == 'PROPFIND' && auth == expected) {
|
||||
request.response.statusCode = 207;
|
||||
request.response.headers.contentType = ContentType('application', 'xml');
|
||||
request.response.write(_multistatusBody);
|
||||
} else if (request.method == 'PROPFIND') {
|
||||
request.response.statusCode = 401;
|
||||
} else {
|
||||
request.response.statusCode = 404;
|
||||
}
|
||||
await request.response.close();
|
||||
}));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close(force: true);
|
||||
});
|
||||
|
||||
test('signInWithCredentials persists credentials that a fresh instance can restore', () async {
|
||||
final first = WebDavProvider();
|
||||
await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password);
|
||||
expect(first.isSignedIn, isTrue);
|
||||
|
||||
// A brand new instance has no in-memory session — simulates the app
|
||||
// process having been killed and relaunched.
|
||||
final afterRestart = WebDavProvider();
|
||||
expect(afterRestart.isSignedIn, isFalse);
|
||||
|
||||
final restored = await afterRestart.attemptSilentSignIn();
|
||||
|
||||
expect(restored, isTrue);
|
||||
expect(afterRestart.isSignedIn, isTrue);
|
||||
expect(afterRestart.accountLabel, first.accountLabel);
|
||||
});
|
||||
|
||||
test('attemptSilentSignIn returns false, not throws, when nothing was ever stored', () async {
|
||||
final provider = WebDavProvider();
|
||||
|
||||
expect(await provider.attemptSilentSignIn(), isFalse);
|
||||
expect(provider.isSignedIn, isFalse);
|
||||
});
|
||||
|
||||
test('signOut clears stored credentials so a later restart no longer restores', () async {
|
||||
final first = WebDavProvider();
|
||||
await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password);
|
||||
await first.signOut();
|
||||
|
||||
final afterRestart = WebDavProvider();
|
||||
expect(await afterRestart.attemptSilentSignIn(), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSecureStoragePlatform extends FlutterSecureStoragePlatform {
|
||||
final Map<String, String> _store = {};
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String value, required Map<String, String> options}) async {
|
||||
_store[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key, required Map<String, String> options}) async => _store[key];
|
||||
|
||||
@override
|
||||
Future<bool> containsKey({required String key, required Map<String, String> options}) async =>
|
||||
_store.containsKey(key);
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key, required Map<String, String> options}) async {
|
||||
_store.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> readAll({required Map<String, String> options}) async => Map.of(_store);
|
||||
|
||||
@override
|
||||
Future<void> deleteAll({required Map<String, String> options}) async => _store.clear();
|
||||
}
|
||||
122
test/webdav_provider_test.dart
Normal file
122
test/webdav_provider_test.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final baseUrl = Uri.parse('https://cloud.example.com/');
|
||||
|
||||
group('parseWebDavMultistatus', () {
|
||||
// Real WebDAV servers disagree on namespace prefix for the same "DAV:"
|
||||
// namespace — Nextcloud defaults to `d:`, many Apache mod_dav setups
|
||||
// use `D:`, and some use no prefix at all with a default xmlns. All
|
||||
// three must parse identically since the parser matches by local name.
|
||||
test('parses a lowercase d: prefixed response (Nextcloud-style)', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
<d:getetag>"abc123"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/receipts/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
<d:getetag>"def456"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/fuel_tax_tracker.db</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
<d:getetag>"ghi789"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries, hasLength(3));
|
||||
expect(entries[0].path, '/remote.php/dav/files/user/MO-Fuel-Tax-Back/');
|
||||
expect(entries[0].isCollection, isTrue);
|
||||
expect(entries[0].etag, '"abc123"');
|
||||
expect(entries[1].isCollection, isTrue);
|
||||
expect(entries[2].path, endsWith('fuel_tax_tracker.db'));
|
||||
expect(entries[2].isCollection, isFalse);
|
||||
expect(entries[2].etag, '"ghi789"');
|
||||
});
|
||||
|
||||
test('parses an uppercase D: prefixed response identically', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/dav/MO-Fuel-Tax-Back/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:getetag>"xyz111"</D:getetag>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries.first.isCollection, isTrue);
|
||||
expect(entries.first.etag, '"xyz111"');
|
||||
});
|
||||
|
||||
test('parses an unprefixed default-namespace response identically', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<multistatus xmlns="DAV:">
|
||||
<response>
|
||||
<href>/dav/MO-Fuel-Tax-Back/lock-file.lock</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype/>
|
||||
<getetag>"lock999"</getetag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
</response>
|
||||
</multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries.first.isCollection, isFalse);
|
||||
expect(entries.first.path, '/dav/MO-Fuel-Tax-Back/lock-file.lock');
|
||||
});
|
||||
|
||||
test('a file with no getetag prop yields a null etag', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/no-etag.txt</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries.single.etag, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue