mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
fix: slow hash reconcilation (#25503)
* fix: slow hash reconcilation * tests for reconcileHashesFromCloudId * paginate cloud id fetch in migrate cloud id * pr review * skip cloudId sync --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
3b0be896e6
commit
836d22570f
15 changed files with 518 additions and 188 deletions
BIN
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
BIN
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
Binary file not shown.
|
|
@ -41,7 +41,7 @@ class HashService {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
|
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
|
||||||
await _migrateHashes();
|
await _localAssetRepository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
// Sorted by backupSelection followed by isCloud
|
// Sorted by backupSelection followed by isCloud
|
||||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||||
|
|
@ -78,15 +78,6 @@ class HashService {
|
||||||
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateHashes() async {
|
|
||||||
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
|
|
||||||
if (hashMappings.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _localAssetRepository.updateHashes(hashMappings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
|
|
|
||||||
|
|
@ -50,75 +50,84 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
|
|
||||||
// Deduplicate mappings as a single remote asset ID can match multiple local assets
|
|
||||||
final seenRemoteAssetIds = <String>{};
|
|
||||||
final uniqueMapping = mappingsToUpdate.where((mapping) {
|
|
||||||
if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
|
||||||
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final assetApi = ref.read(apiServiceProvider).assetsApi;
|
final assetApi = ref.read(apiServiceProvider).assetsApi;
|
||||||
|
|
||||||
if (canBulkUpdateMetadata) {
|
// Process cloud IDs in paginated batches
|
||||||
await _bulkUpdateCloudIds(assetApi, uniqueMapping);
|
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
Future<void> _processCloudIdMappingsInBatches(
|
||||||
for (final mapping in mappings) {
|
Drift drift,
|
||||||
final item = AssetMetadataUpsertItemDto(
|
String userId,
|
||||||
key: kMobileMetadataKey,
|
AssetsApi assetsApi,
|
||||||
value: RemoteAssetMobileAppMetadata(
|
bool canBulkUpdate,
|
||||||
cloudId: mapping.localAsset.cloudId,
|
Logger logger,
|
||||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
) async {
|
||||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
const pageSize = 20000;
|
||||||
latitude: mapping.localAsset.latitude?.toString(),
|
String? lastLocalId;
|
||||||
longitude: mapping.localAsset.longitude?.toString(),
|
final seenRemoteAssetIds = <String>{};
|
||||||
),
|
|
||||||
);
|
while (true) {
|
||||||
try {
|
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
|
||||||
await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item]));
|
if (mappings.isEmpty) {
|
||||||
} catch (error, stack) {
|
break;
|
||||||
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
|
||||||
const batchSize = 10000;
|
|
||||||
for (int i = 0; i < mappings.length; i += batchSize) {
|
|
||||||
final endIndex = (i + batchSize > mappings.length) ? mappings.length : i + batchSize;
|
|
||||||
final batch = mappings.sublist(i, endIndex);
|
|
||||||
final items = <AssetMetadataBulkUpsertItemDto>[];
|
final items = <AssetMetadataBulkUpsertItemDto>[];
|
||||||
for (final mapping in batch) {
|
for (final mapping in mappings) {
|
||||||
items.add(
|
if (seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
||||||
AssetMetadataBulkUpsertItemDto(
|
items.add(
|
||||||
assetId: mapping.remoteAssetId,
|
AssetMetadataBulkUpsertItemDto(
|
||||||
key: kMobileMetadataKey,
|
assetId: mapping.remoteAssetId,
|
||||||
value: RemoteAssetMobileAppMetadata(
|
key: kMobileMetadataKey,
|
||||||
cloudId: mapping.localAsset.cloudId,
|
value: RemoteAssetMobileAppMetadata(
|
||||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
cloudId: mapping.localAsset.cloudId,
|
||||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||||
latitude: mapping.localAsset.latitude?.toString(),
|
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||||
longitude: mapping.localAsset.longitude?.toString(),
|
latitude: mapping.localAsset.latitude?.toString(),
|
||||||
|
longitude: mapping.localAsset.longitude?.toString(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
} else {
|
||||||
|
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
if (canBulkUpdate) {
|
||||||
|
await _bulkUpdateCloudIds(assetsApi, items);
|
||||||
|
} else {
|
||||||
|
await _sequentialUpdateCloudIds(assetsApi, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLocalId = mappings.last.localAsset.id;
|
||||||
|
if (mappings.length < pageSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
|
||||||
|
for (final item in items) {
|
||||||
|
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
|
||||||
try {
|
try {
|
||||||
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
|
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
|
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
|
||||||
|
try {
|
||||||
|
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
|
||||||
|
} catch (error, stack) {
|
||||||
|
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _populateCloudIds(Drift drift) async {
|
Future<void> _populateCloudIds(Drift drift) async {
|
||||||
final query = drift.localAssetEntity.selectOnly()
|
final query = drift.localAssetEntity.selectOnly()
|
||||||
..addColumns([drift.localAssetEntity.id])
|
..addColumns([drift.localAssetEntity.id])
|
||||||
|
|
@ -141,31 +150,38 @@ Future<void> _populateCloudIds(Drift drift) async {
|
||||||
|
|
||||||
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
|
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
|
||||||
|
|
||||||
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async {
|
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId, int limit, String? lastLocalId) async {
|
||||||
final query =
|
final query =
|
||||||
drift.remoteAssetEntity.select().join([
|
drift.localAssetEntity.select().join([
|
||||||
leftOuterJoin(
|
innerJoin(
|
||||||
drift.localAssetEntity,
|
drift.remoteAssetEntity,
|
||||||
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
|
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
|
||||||
),
|
),
|
||||||
leftOuterJoin(
|
leftOuterJoin(
|
||||||
drift.remoteAssetCloudIdEntity,
|
drift.remoteAssetCloudIdEntity,
|
||||||
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
|
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
|
||||||
useColumns: false,
|
useColumns: false,
|
||||||
),
|
),
|
||||||
])..where(
|
])
|
||||||
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
|
..where(
|
||||||
drift.localAssetEntity.id.isNotNull() &
|
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
|
||||||
drift.localAssetEntity.iCloudId.isNotNull() &
|
drift.localAssetEntity.iCloudId.isNotNull() &
|
||||||
drift.remoteAssetEntity.ownerId.equals(userId) &
|
drift.remoteAssetEntity.ownerId.equals(userId) &
|
||||||
// Skip locked assets as we cannot update them without unlocking first
|
// Skip locked assets as we cannot update them without unlocking first
|
||||||
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
||||||
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
||||||
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
||||||
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
||||||
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
||||||
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
||||||
);
|
)
|
||||||
|
..orderBy([OrderingTerm.asc(drift.localAssetEntity.id)])
|
||||||
|
..limit(limit);
|
||||||
|
|
||||||
|
if (lastLocalId != null) {
|
||||||
|
query.where(drift.localAssetEntity.id.isBiggerThanValue(lastLocalId));
|
||||||
|
}
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
return (
|
return (
|
||||||
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
|
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||||
|
|
||||||
|
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)')
|
||||||
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
|
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
|
||||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 17;
|
int get schemaVersion => 18;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
|
@ -204,6 +204,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||||
from16To17: (m, v17) async {
|
from16To17: (m, v17) async {
|
||||||
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
|
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
|
||||||
},
|
},
|
||||||
|
from17To18: (m, v18) async {
|
||||||
|
await m.createIndex(v18.idxRemoteAssetCloudId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -204,34 +204,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
return query.map((row) => row.toDto()).get();
|
return query.map((row) => row.toDto()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, String>> getHashMappingFromCloudId() async {
|
Future<void> reconcileHashesFromCloudId() async {
|
||||||
final query =
|
await _db.customUpdate(
|
||||||
_db.localAssetEntity.selectOnly().join([
|
'''
|
||||||
leftOuterJoin(
|
UPDATE local_asset_entity
|
||||||
_db.remoteAssetCloudIdEntity,
|
SET checksum = remote_asset_entity.checksum
|
||||||
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId),
|
FROM remote_asset_cloud_id_entity
|
||||||
useColumns: false,
|
INNER JOIN remote_asset_entity
|
||||||
),
|
ON remote_asset_cloud_id_entity.asset_id = remote_asset_entity.id
|
||||||
leftOuterJoin(
|
WHERE local_asset_entity.i_cloud_id = remote_asset_cloud_id_entity.cloud_id
|
||||||
_db.remoteAssetEntity,
|
AND local_asset_entity.checksum IS NULL
|
||||||
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
|
AND remote_asset_cloud_id_entity.adjustment_time IS local_asset_entity.adjustment_time
|
||||||
useColumns: false,
|
AND remote_asset_cloud_id_entity.latitude IS local_asset_entity.latitude
|
||||||
),
|
AND remote_asset_cloud_id_entity.longitude IS local_asset_entity.longitude
|
||||||
])
|
AND remote_asset_cloud_id_entity.created_at IS local_asset_entity.created_at
|
||||||
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum])
|
''',
|
||||||
..where(
|
updates: {_db.localAssetEntity},
|
||||||
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() &
|
updateKind: UpdateKind.update,
|
||||||
_db.localAssetEntity.checksum.isNull() &
|
);
|
||||||
((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) &
|
|
||||||
(_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) &
|
|
||||||
(_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) &
|
|
||||||
(_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))),
|
|
||||||
);
|
|
||||||
final mapping = await query
|
|
||||||
.map(
|
|
||||||
(row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
return {for (final entry in mapping) entry.assetId: entry.checksum};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||||
_resumeBackup(backupProvider);
|
_resumeBackup(backupProvider);
|
||||||
}),
|
}),
|
||||||
_resumeBackup(backupProvider),
|
_resumeBackup(backupProvider),
|
||||||
backgroundManager.syncCloudIds(),
|
// TODO: Bring back when the soft freeze issue is addressed
|
||||||
|
// backgroundManager.syncCloudIds(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await backgroundManager.hashAssets();
|
await backgroundManager.hashAssets();
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||||
_resumeBackup();
|
_resumeBackup();
|
||||||
}),
|
}),
|
||||||
_resumeBackup(),
|
_resumeBackup(),
|
||||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
// TODO: Bring back when the soft freeze issue is addressed
|
||||||
|
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ void main() {
|
||||||
registerFallbackValue(LocalAssetStub.image1);
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
registerFallbackValue(<String, String>{});
|
registerFallbackValue(<String, String>{});
|
||||||
|
|
||||||
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
|
when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -191,5 +191,4 @@ void main() {
|
||||||
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
mobile/test/drift/main/generated/schema.dart
generated
BIN
mobile/test/drift/main/generated/schema.dart
generated
Binary file not shown.
BIN
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
BIN
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart' hide isNull;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
|
@ -8,11 +8,13 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
final now = DateTime(2024, 1, 15);
|
||||||
late Drift db;
|
late Drift db;
|
||||||
late DriftLocalAssetRepository repository;
|
late DriftLocalAssetRepository repository;
|
||||||
|
|
||||||
|
|
@ -25,68 +27,98 @@ void main() {
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<void> insertLocalAsset({
|
||||||
|
required String id,
|
||||||
|
String? checksum,
|
||||||
|
DateTime? createdAt,
|
||||||
|
AssetType type = AssetType.image,
|
||||||
|
bool isFavorite = false,
|
||||||
|
String? iCloudId,
|
||||||
|
DateTime? adjustmentTime,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
}) async {
|
||||||
|
final created = createdAt ?? now;
|
||||||
|
await db
|
||||||
|
.into(db.localAssetEntity)
|
||||||
|
.insert(
|
||||||
|
LocalAssetEntityCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
name: 'asset_$id.jpg',
|
||||||
|
checksum: Value(checksum),
|
||||||
|
type: type,
|
||||||
|
createdAt: Value(created),
|
||||||
|
updatedAt: Value(created),
|
||||||
|
isFavorite: Value(isFavorite),
|
||||||
|
iCloudId: Value(iCloudId),
|
||||||
|
adjustmentTime: Value(adjustmentTime),
|
||||||
|
latitude: Value(latitude),
|
||||||
|
longitude: Value(longitude),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertRemoteAsset({
|
||||||
|
required String id,
|
||||||
|
required String checksum,
|
||||||
|
required String ownerId,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
}) async {
|
||||||
|
await db
|
||||||
|
.into(db.remoteAssetEntity)
|
||||||
|
.insert(
|
||||||
|
RemoteAssetEntityCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
name: 'remote_$id.jpg',
|
||||||
|
checksum: checksum,
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: Value(now),
|
||||||
|
updatedAt: Value(now),
|
||||||
|
ownerId: ownerId,
|
||||||
|
visibility: AssetVisibility.timeline,
|
||||||
|
deletedAt: Value(deletedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertRemoteAssetCloudId({
|
||||||
|
required String assetId,
|
||||||
|
required String? cloudId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? adjustmentTime,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
}) async {
|
||||||
|
await db
|
||||||
|
.into(db.remoteAssetCloudIdEntity)
|
||||||
|
.insert(
|
||||||
|
RemoteAssetCloudIdEntityCompanion.insert(
|
||||||
|
assetId: assetId,
|
||||||
|
cloudId: Value(cloudId),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
adjustmentTime: Value(adjustmentTime),
|
||||||
|
latitude: Value(latitude),
|
||||||
|
longitude: Value(longitude),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertUser(String id, String email) async {
|
||||||
|
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
|
||||||
|
}
|
||||||
|
|
||||||
group('getRemovalCandidates', () {
|
group('getRemovalCandidates', () {
|
||||||
final userId = 'user-123';
|
final userId = 'user-123';
|
||||||
final otherUserId = 'user-456';
|
final otherUserId = 'user-456';
|
||||||
final now = DateTime(2024, 1, 15);
|
|
||||||
final cutoffDate = DateTime(2024, 1, 10);
|
final cutoffDate = DateTime(2024, 1, 10);
|
||||||
final beforeCutoff = DateTime(2024, 1, 5);
|
final beforeCutoff = DateTime(2024, 1, 5);
|
||||||
final afterCutoff = DateTime(2024, 1, 12);
|
final afterCutoff = DateTime(2024, 1, 12);
|
||||||
|
|
||||||
Future<void> insertUser(String id, String email) async {
|
|
||||||
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
|
|
||||||
}
|
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
await insertUser(userId, 'user@test.com');
|
await insertUser(userId, 'user@test.com');
|
||||||
await insertUser(otherUserId, 'other@test.com');
|
await insertUser(otherUserId, 'other@test.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> insertLocalAsset({
|
|
||||||
required String id,
|
|
||||||
required String checksum,
|
|
||||||
required DateTime createdAt,
|
|
||||||
required AssetType type,
|
|
||||||
required bool isFavorite,
|
|
||||||
}) async {
|
|
||||||
await db
|
|
||||||
.into(db.localAssetEntity)
|
|
||||||
.insert(
|
|
||||||
LocalAssetEntityCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
name: 'asset_$id.jpg',
|
|
||||||
checksum: Value(checksum),
|
|
||||||
type: type,
|
|
||||||
createdAt: Value(createdAt),
|
|
||||||
updatedAt: Value(createdAt),
|
|
||||||
isFavorite: Value(isFavorite),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> insertRemoteAsset({
|
|
||||||
required String id,
|
|
||||||
required String checksum,
|
|
||||||
required String ownerId,
|
|
||||||
DateTime? deletedAt,
|
|
||||||
}) async {
|
|
||||||
await db
|
|
||||||
.into(db.remoteAssetEntity)
|
|
||||||
.insert(
|
|
||||||
RemoteAssetEntityCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
name: 'remote_$id.jpg',
|
|
||||||
checksum: checksum,
|
|
||||||
type: AssetType.image,
|
|
||||||
createdAt: Value(now),
|
|
||||||
updatedAt: Value(now),
|
|
||||||
ownerId: ownerId,
|
|
||||||
visibility: AssetVisibility.timeline,
|
|
||||||
deletedAt: Value(deletedAt),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
|
Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
|
||||||
await db
|
await db
|
||||||
.into(db.localAlbumEntity)
|
.into(db.localAlbumEntity)
|
||||||
|
|
@ -211,11 +243,7 @@ void main() {
|
||||||
);
|
);
|
||||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||||
|
|
||||||
final result = await repository.getRemovalCandidates(
|
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly);
|
||||||
userId,
|
|
||||||
cutoffDate,
|
|
||||||
keepMediaType: AssetKeepType.photosOnly,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.assets.length, 1);
|
expect(result.assets.length, 1);
|
||||||
expect(result.assets[0].id, 'local-video');
|
expect(result.assets[0].id, 'local-video');
|
||||||
|
|
@ -243,11 +271,7 @@ void main() {
|
||||||
);
|
);
|
||||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||||
|
|
||||||
final result = await repository.getRemovalCandidates(
|
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly);
|
||||||
userId,
|
|
||||||
cutoffDate,
|
|
||||||
keepMediaType: AssetKeepType.videosOnly,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.assets.length, 1);
|
expect(result.assets.length, 1);
|
||||||
expect(result.assets[0].id, 'local-photo');
|
expect(result.assets[0].id, 'local-photo');
|
||||||
|
|
@ -507,11 +531,7 @@ void main() {
|
||||||
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
|
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
|
||||||
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
|
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
|
||||||
|
|
||||||
final result = await repository.getRemovalCandidates(
|
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-1', 'album-2'});
|
||||||
userId,
|
|
||||||
cutoffDate,
|
|
||||||
keepAlbumIds: {'album-1', 'album-2'},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.assets.length, 1);
|
expect(result.assets.length, 1);
|
||||||
expect(result.assets[0].id, 'local-3');
|
expect(result.assets[0].id, 'local-3');
|
||||||
|
|
@ -644,4 +664,313 @@ void main() {
|
||||||
expect(result.assets[0].id, 'local-video');
|
expect(result.assets[0].id, 'local-video');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('reconcileHashesFromCloudId', () {
|
||||||
|
final userId = 'user-123';
|
||||||
|
final createdAt = DateTime(2024, 1, 10);
|
||||||
|
final adjustmentTime = DateTime(2024, 1, 11);
|
||||||
|
const latitude = 37.7749;
|
||||||
|
const longitude = -122.4194;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
await insertUser(userId, 'user@test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates local asset checksum when all metadata matches', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, 'hash-abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when local asset already has checksum', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: 'existing-checksum',
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, 'existing-checksum');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when adjustment_time does not match', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: DateTime(2024, 1, 12),
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when latitude does not match', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: 40.7128,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when longitude does not match', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: -74.0060,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when createdAt does not match', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: DateTime(2024, 1, 5),
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when iCloudId is null', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: null,
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when cloudId does not match iCloudId', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-456',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles partial null metadata fields matching correctly', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: null,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: null,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, 'hash-abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update when one has null and other has value', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: null,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||||
|
|
||||||
|
await insertRemoteAssetCloudId(
|
||||||
|
assetId: 'remote-1',
|
||||||
|
cloudId: 'cloud-123',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles no matching assets gracefully', () async {
|
||||||
|
await insertLocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
checksum: null,
|
||||||
|
iCloudId: 'cloud-999',
|
||||||
|
createdAt: createdAt,
|
||||||
|
adjustmentTime: adjustmentTime,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.reconcileHashesFromCloudId();
|
||||||
|
|
||||||
|
final updated = await repository.getById('local-1');
|
||||||
|
expect(updated?.checksum, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue