403 lines
15 KiB
Dart
403 lines
15 KiB
Dart
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:fuel_tax_tracker/services/db_schema.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('selectAppFolder creates/finds the app folder under the picked parent', () async {
|
|
final session = _FakeSession();
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
|
|
final folderId = await syncService.selectAppFolder('parent-id');
|
|
|
|
expect(folderId, 'parent-id/$appFolderName');
|
|
expect(session.findOrCreateFolderCalls, ['parent-id/$appFolderName']);
|
|
});
|
|
|
|
test('selectAppFolder uses the picked folder directly if it is already the app folder',
|
|
() async {
|
|
final session = _FakeSession();
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
|
|
final folderId =
|
|
await syncService.selectAppFolder('parent-id/$appFolderName', currentFolderName: appFolderName);
|
|
|
|
expect(folderId, 'parent-id/$appFolderName');
|
|
expect(session.findOrCreateFolderCalls, isEmpty,
|
|
reason: 'should not nest another app folder inside a folder already named that');
|
|
});
|
|
|
|
test(
|
|
'selectAppFolder moves an already-configured app folder to a new parent instead of '
|
|
'creating a fresh one there', () async {
|
|
final session = _FakeSession();
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
// Establishes an app folder under the first parent — e.g. the root
|
|
// default a fresh connection assumes (see AppState.connectProvider) —
|
|
// before the user picks somewhere else.
|
|
await syncService.selectAppFolder('root');
|
|
expect(syncService.isConfigured, isTrue);
|
|
|
|
final newFolderId = await syncService.selectAppFolder('new-parent-id');
|
|
|
|
expect(session.moveFolderCalls, ['root/$appFolderName->new-parent-id']);
|
|
expect(newFolderId, 'new-parent-id/moved');
|
|
expect(session.findOrCreateFolderCalls, ['root/$appFolderName'],
|
|
reason: 'only the very first (root) selection created a folder; the second moved it');
|
|
});
|
|
|
|
test(
|
|
'selectAppFolder adopts a same-named folder already at the destination instead of '
|
|
'moving onto/alongside it', () async {
|
|
final session = _FakeSession()
|
|
..foldersByParent['new-parent-id'] = [CloudFolder(id: 'shared-folder-id', name: appFolderName)];
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
await syncService.selectAppFolder('root');
|
|
|
|
final folderId = await syncService.selectAppFolder('new-parent-id');
|
|
|
|
expect(folderId, 'shared-folder-id');
|
|
expect(session.moveFolderCalls, isEmpty, reason: 'adopted the existing one, did not move onto it');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
group('beforeUpload hook', () {
|
|
test('is awaited after pulling/merging remote changes but before anything is uploaded',
|
|
() async {
|
|
final calls = <String>[];
|
|
// A real copy of the (correctly schema'd, currently empty) local
|
|
// database — CloudSyncService ATTACHes and merges against whatever
|
|
// downloadFileBytes returns, so it has to be an actual valid SQLite
|
|
// database, not arbitrary bytes.
|
|
final remoteDbBytes = await File(databaseService.databasePath).readAsBytes();
|
|
final session = _FakeSession();
|
|
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
|
|
session.remoteDbBytes = remoteDbBytes;
|
|
session.onDownload = () => calls.add('download');
|
|
session.onUpload = (name) => calls.add('upload:$name');
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
syncService.configure('app-folder-id');
|
|
|
|
final result = await syncService.syncNow(
|
|
beforeUpload: () async {
|
|
calls.add('beforeUpload');
|
|
return true;
|
|
},
|
|
);
|
|
|
|
expect(result.ranSync, isTrue);
|
|
expect(result.adGateBlocked, isFalse);
|
|
expect(calls.first, 'download', reason: 'pull/merge happens first');
|
|
expect(calls[1], 'beforeUpload');
|
|
// Everything after beforeUpload is an upload (the data-file push,
|
|
// possibly preceded by pending receipt uploads — none pending here).
|
|
expect(calls.skip(2), everyElement(startsWith('upload:')));
|
|
expect(calls, contains('upload:$dataFileName'));
|
|
});
|
|
|
|
test('is awaited even when there is nothing to pull (no existing remote data file yet)',
|
|
() async {
|
|
final calls = <String>[];
|
|
final session = _FakeSession()..onUpload = (name) => calls.add('upload:$name');
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
syncService.configure('app-folder-id');
|
|
|
|
await syncService.syncNow(
|
|
beforeUpload: () async {
|
|
calls.add('beforeUpload');
|
|
return true;
|
|
},
|
|
);
|
|
|
|
expect(calls.first, 'beforeUpload');
|
|
expect(calls, contains('upload:$dataFileName'));
|
|
});
|
|
|
|
test('a false return blocks the upload but keeps whatever was already pulled/merged',
|
|
() async {
|
|
final calls = <String>[];
|
|
final remoteDbBytes = await File(databaseService.databasePath).readAsBytes();
|
|
final session = _FakeSession();
|
|
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
|
|
session.remoteDbBytes = remoteDbBytes;
|
|
session.onDownload = () => calls.add('download');
|
|
session.onUpload = (name) => calls.add('upload:$name');
|
|
final provider = _FakeProvider(session);
|
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
|
syncService.configure('app-folder-id');
|
|
|
|
final result = await syncService.syncNow(beforeUpload: () async => false);
|
|
|
|
expect(result.ranSync, isFalse);
|
|
expect(result.adGateBlocked, isTrue);
|
|
expect(calls, ['download'], reason: 'pull/merge still ran; nothing was uploaded');
|
|
expect(session.deletedFileIds, ['lock-id'],
|
|
reason: 'the lock must still be released when the gate blocks the upload');
|
|
});
|
|
|
|
test('omitting it changes nothing — every other syncNow call site keeps working', () 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);
|
|
});
|
|
});
|
|
|
|
test(
|
|
'pull/merge does not throw against a remote snapshot from before the '
|
|
'ad_free_entitlement table existed', () async {
|
|
// Simulates a real user's pre-existing cloud folder: a DB file uploaded
|
|
// by an older app version, with only the schema that existed then (no
|
|
// ad_free_entitlement table) — CloudSyncService must skip merging that
|
|
// table rather than throw "no such table: remote_db.ad_free_entitlement".
|
|
final oldSchemaDbPath = p.join(tempDir.path, 'old_schema_remote.db');
|
|
final oldSchemaDb = await databaseFactory.openDatabase(oldSchemaDbPath);
|
|
await oldSchemaDb.execute(createVehiclesTableSql);
|
|
await oldSchemaDb.execute(createFuelEntriesTableSql);
|
|
await oldSchemaDb.execute(createFuelEntriesIndexSql);
|
|
await oldSchemaDb.close();
|
|
|
|
final session = _FakeSession();
|
|
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
|
|
session.remoteDbBytes = await File(oldSchemaDbPath).readAsBytes();
|
|
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);
|
|
});
|
|
}
|
|
|
|
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 = {};
|
|
final List<String> findOrCreateFolderCalls = [];
|
|
CloudFileInfo? existingDataFile;
|
|
void Function()? onDownload;
|
|
void Function(String name)? onUpload;
|
|
|
|
/// Bytes [downloadFileBytes] returns — needs to be a real, attachable
|
|
/// SQLite database (with the vehicles/fuel_entries schema) for
|
|
/// [CloudSyncService]'s ATTACH-based merge to succeed against it, not
|
|
/// just arbitrary bytes. Tests that exercise the pull/merge path set this
|
|
/// to a copy of their own [DatabaseService]'s (already correctly
|
|
/// schema'd) database file.
|
|
List<int> remoteDbBytes = const [];
|
|
|
|
@override
|
|
bool get supportsSharedWithMe => false;
|
|
|
|
/// Folders [listFolders] returns, keyed by the `parentId` they'd be
|
|
/// listed under — empty (nothing at any destination) unless a test sets
|
|
/// this up, e.g. to simulate a same-named app folder someone else
|
|
/// already created at the destination.
|
|
final Map<String, List<CloudFolder>> foldersByParent = {};
|
|
|
|
@override
|
|
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async =>
|
|
foldersByParent[parentId] ?? [];
|
|
|
|
@override
|
|
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
|
findOrCreateFolderCalls.add('$parentId/$name');
|
|
return '$parentId/$name';
|
|
}
|
|
|
|
final List<String> moveFolderCalls = [];
|
|
|
|
@override
|
|
Future<String> moveFolder({required String folderId, required String newParentId}) async {
|
|
moveFolderCalls.add('$folderId->$newParentId');
|
|
return '$newParentId/moved';
|
|
}
|
|
|
|
@override
|
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
|
return name == dataFileName ? existingDataFile : null;
|
|
}
|
|
|
|
@override
|
|
Future<List<int>> downloadFileBytes(String fileId) async {
|
|
onDownload?.call();
|
|
return remoteDbBytes;
|
|
}
|
|
|
|
@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;
|
|
onUpload?.call(name);
|
|
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;
|
|
}
|