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//', () 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 = []; // 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 = []; 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 = []; 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 getApplicationDocumentsPath() async => tempPath; @override Future getTemporaryPath() async => tempPath; } class _FakeSession implements CloudStorageSession { final List createdLockNames = []; final List deletedFileIds = []; final List uploadedFileNames = []; final Map uploadedFileFolders = {}; final List 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 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> foldersByParent = {}; @override Future> listFolders({String? parentId, bool sharedWithMe = false}) async => foldersByParent[parentId] ?? []; @override Future findOrCreateFolder({required String parentId, required String name}) async { findOrCreateFolderCalls.add('$parentId/$name'); return '$parentId/$name'; } final List moveFolderCalls = []; @override Future moveFolder({required String folderId, required String newParentId}) async { moveFolderCalls.add('$folderId->$newParentId'); return '$newParentId/moved'; } @override Future findFile({required String folderId, required String name}) async { return name == dataFileName ? existingDataFile : null; } @override Future> downloadFileBytes(String fileId) async { onDownload?.call(); return remoteDbBytes; } @override Future 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 deleteFile(String fileId) async => deletedFileIds.add(fileId); @override Future createLockFile({required String folderId, required String name}) async { createdLockNames.add(name); return 'lock-id'; } @override Future> 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 attemptSilentSignIn() async => true; @override Future signIn() async => accountLabel!; @override Future signOut() async {} @override CloudStorageSession beginSession() => session; }