Remote sync of webdav

This commit is contained in:
Courtney Arnold 2026-08-13 17:05:10 -05:00
parent f01186df79
commit 483fbeafb6
44 changed files with 4842 additions and 975 deletions

View 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;
}

View file

@ -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,

View file

@ -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 {},

View 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);
});
}

View file

@ -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));
});
});
}

View 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;
}

View 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
View 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);
});
});
}

View 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();
}

View 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>&quot;abc123&quot;</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>&quot;def456&quot;</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>&quot;ghi789&quot;</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);
});
});
}