Ad and purchases enabled (still need actual testing)

This commit is contained in:
Courtney Arnold 2026-08-16 18:27:56 -05:00
parent 2fcc6dcab7
commit faf319322a
35 changed files with 1676 additions and 34 deletions

View file

@ -4,6 +4,7 @@ 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';
@ -114,6 +115,41 @@ void main() {
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);
@ -124,6 +160,89 @@ void main() {
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'),
);
expect(result.ranSync, isTrue);
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'));
expect(calls.first, 'beforeUpload');
expect(calls, contains('upload:$dataFileName'));
});
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 {
@ -144,12 +263,29 @@ class _FakeSession implements CloudStorageSession {
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 => [];
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async =>
foldersByParent[parentId] ?? [];
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
@ -157,13 +293,24 @@ class _FakeSession implements CloudStorageSession {
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 => <int>[];
Future<List<int>> downloadFileBytes(String fileId) async {
onDownload?.call();
return remoteDbBytes;
}
@override
Future<CloudFileInfo> uploadFile({
@ -175,6 +322,7 @@ class _FakeSession implements CloudStorageSession {
}) async {
uploadedFileNames.add(name);
uploadedFileFolders[name] = folderId;
onUpload?.call(name);
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
}