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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue