diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index c43de69c5d..fc3f8f9314 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -224,6 +224,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } @@ -263,6 +264,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index bd47f63da5..13da161149 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -3,6 +3,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -25,17 +26,18 @@ SyncAssetV1 _createAsset({ required String id, required String checksum, required String fileName, - String ownerId = 'user-1', int? width, int? height, + String? libraryId, + bool isFavorite = false, }) { return SyncAssetV1( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, - isFavorite: false, + ownerId: 'user-1', + isFavorite: isFavorite, fileCreatedAt: DateTime(2024, 1, 1), fileModifiedAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), @@ -45,6 +47,31 @@ SyncAssetV1 _createAsset({ height: height, deletedAt: null, duration: null, + libraryId: libraryId, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + isEdited: false, + ); +} + +SyncAssetV2 _createAssetV2({required String id, required String checksum, required String fileName}) { + return SyncAssetV2( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: 'user-1', + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: null, + height: null, + deletedAt: null, + duration: 0, libraryId: null, livePhotoVideoId: null, stackId: null, @@ -240,4 +267,100 @@ void main() { expect(after.backupSelection, equals(BackupSelection.none)); }); }); + + group('SyncStreamRepository - updateAssets upsert dedupe (#22522 #27186)', () { + Future seedExif(String assetId) => + db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); + + Future exifExists(String assetId) async { + final rows = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + return rows.isNotEmpty; + } + + test('same-id update keeps the child row and updates fields', () async { + await sut.updateUsersV1([_createUser()]); + final asset = _createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([asset]); + await seedExif(asset.id); + + final renamed = _createAsset(id: asset.id, checksum: asset.checksum, fileName: 'renamed.jpg', isFavorite: true); + await sut.updateAssetsV1([renamed]); + + expect(await exifExists(asset.id), isTrue, reason: 'DO UPDATE keeps the row, the child survives'); + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle(); + expect(row.name, renamed.originalFileName); + expect(row.isFavorite, isTrue); + }); + + test('reupload with a new id replaces the stale row and cascades its child', () async { + await sut.updateUsersV1([_createUser()]); + final stale = _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([stale]); + await seedExif(stale.id); + + final fresh = _createAsset(id: 'fresh', checksum: stale.checksum, fileName: stale.originalFileName); + await sut.updateAssetsV1([fresh]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1), reason: 'no 2067, stale row replaced away'); + expect(rows.single.id, fresh.id); + expect(await exifExists(stale.id), isFalse, reason: 'the stale child cascades with the replaced row'); + + // same scenario through V2 + final staleV2 = _createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg'); + await sut.updateAssetsV2([staleV2]); + await seedExif(staleV2.id); + final freshV2 = _createAssetV2(id: 'fresh2', checksum: staleV2.checksum, fileName: staleV2.originalFileName); + await sut.updateAssetsV2([freshV2]); + + final rows2 = await db.remoteAssetEntity.select().get(); + expect(rows2.map((r) => r.id), containsAll([fresh.id, freshV2.id])); + expect(await exifExists(staleV2.id), isFalse); + }); + + test('library variant replaces only the matching library row', () async { + await sut.updateUsersV1([_createUser()]); + final staleLib = _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'); + final keepNull = _createAsset(id: 'keep-null', checksum: staleLib.checksum, fileName: staleLib.originalFileName); + await sut.updateAssetsV1([staleLib, keepNull]); + + final freshLib = _createAsset( + id: 'fresh-lib', + checksum: staleLib.checksum, + fileName: staleLib.originalFileName, + libraryId: staleLib.libraryId, + ); + await sut.updateAssetsV1([freshLib]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows.map((r) => r.id).toSet(), { + freshLib.id, + keepNull.id, + }, reason: 'library NULL and NOT NULL match different partial indexes'); + }); + + test('batch-internal duplicates keep the last payload asset', () async { + await sut.updateUsersV1([_createUser()]); + final first = _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'); + final last = _createAsset(id: 'last-id', checksum: first.checksum, fileName: first.originalFileName); + final firstLib = _createAsset( + id: 'first-lib', + checksum: 'BBB', + fileName: first.originalFileName, + libraryId: 'lib-1', + ); + final lastLib = _createAsset( + id: 'last-lib', + checksum: firstLib.checksum, + fileName: firstLib.originalFileName, + libraryId: firstLib.libraryId, + ); + + await sut.updateAssetsV1([first, last, firstLib, lastLib]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(2), reason: 'REPLACE makes batch-internal duplicates last-wins, no crash'); + expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id}); + }); + }); }