From 281d88c034c786325a4e60ba892deabab6842a90 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 5 Aug 2026 18:00:42 +0600 Subject: [PATCH] trim the dedupe tests to the essentials --- .../sync_stream_repository_test.dart | 294 +++++------------- 1 file changed, 72 insertions(+), 222 deletions(-) diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 9111e376c2..13da161149 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -3,23 +3,17 @@ 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/domain/models/asset/base_asset.model.dart' as domain; -import 'package:immich_mobile/domain/models/memory.model.dart'; -import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.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/memory.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:openapi/api.dart'; -SyncUserV1 _createUser({String id = 'user-1', String name = 'Test User'}) { +SyncUserV1 _createUser({String id = 'user-1'}) { return SyncUserV1( id: id, - name: name, + name: 'Test User', email: 'test@test.com', deletedAt: null, avatarColor: const Optional.absent(), @@ -32,56 +26,42 @@ SyncAssetV1 _createAsset({ required String id, required String checksum, required String fileName, - String ownerId = 'user-1', int? width, int? height, String? libraryId, bool isFavorite = false, - AssetVisibility visibility = AssetVisibility.timeline, - String? stackId, - String? livePhotoVideoId, - String? thumbhash, - DateTime? deletedAt, - DateTime? fileCreatedAt, - DateTime? fileModifiedAt, }) { return SyncAssetV1( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, + ownerId: 'user-1', isFavorite: isFavorite, - fileCreatedAt: fileCreatedAt ?? DateTime(2024, 1, 1), - fileModifiedAt: fileModifiedAt ?? DateTime(2024, 1, 1), + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), localDateTime: DateTime(2024, 1, 1), - visibility: visibility, + visibility: AssetVisibility.timeline, width: width, height: height, - deletedAt: deletedAt, + deletedAt: null, duration: null, libraryId: libraryId, - livePhotoVideoId: livePhotoVideoId, - stackId: stackId, - thumbhash: thumbhash, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, isEdited: false, ); } -SyncAssetV2 _createAssetV2({ - required String id, - required String checksum, - required String fileName, - String ownerId = 'user-1', - String? libraryId, -}) { +SyncAssetV2 _createAssetV2({required String id, required String checksum, required String fileName}) { return SyncAssetV2( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, + ownerId: 'user-1', isFavorite: false, fileCreatedAt: DateTime(2024, 1, 1), fileModifiedAt: DateTime(2024, 1, 1), @@ -92,7 +72,7 @@ SyncAssetV2 _createAssetV2({ height: null, deletedAt: null, duration: 0, - libraryId: libraryId, + libraryId: null, livePhotoVideoId: null, stackId: null, thumbhash: null, @@ -289,228 +269,98 @@ void main() { }); group('SyncStreamRepository - updateAssets upsert dedupe (#22522 #27186)', () { - Future pragmaOn(String testName) async { - await db.customStatement('PRAGMA foreign_keys = ON'); - final row = await db.customSelect('PRAGMA foreign_keys').getSingle(); - // ignore: avoid_print - print('RECEIPT test=$testName pragma foreign_keys=${row.read('foreign_keys')}'); + 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; } - Future seedChildren(String assetId, String tag) async { - await db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); - await db.remoteAlbumEntity.insertOne( - RemoteAlbumEntityCompanion.insert(id: 'album-$tag', name: 'Album', order: AlbumAssetOrder.desc), - ); - await db.remoteAlbumAssetEntity.insertOne( - RemoteAlbumAssetEntityCompanion.insert(assetId: assetId, albumId: 'album-$tag'), - ); - await db.memoryEntity.insertOne( - MemoryEntityCompanion.insert( - id: 'memory-$tag', - ownerId: 'user-1', - type: MemoryTypeEnum.onThisDay, - data: '{"year":2024}', - memoryAt: DateTime(2024, 1, 1), - ), - ); - await db.memoryAssetEntity.insertOne( - MemoryAssetEntityCompanion.insert(assetId: assetId, memoryId: 'memory-$tag'), - ); - await db.assetFaceEntity.insertOne( - AssetFaceEntityCompanion.insert( - id: 'face-$tag', - assetId: assetId, - imageWidth: 100, - imageHeight: 100, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 10, - boundingBoxY2: 10, - sourceType: 'ml', - ), - ); - } - - Future countChildren(String assetId) async { - final exif = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - final albumAsset = await (db.remoteAlbumAssetEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - final memoryAsset = await (db.memoryAssetEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - final faces = await (db.assetFaceEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - return exif.length + albumAsset.length + memoryAsset.length + faces.length; - } - - test('same-id update keeps children and updates fields', () async { - await pragmaOn('same-id'); + test('same-id update keeps the child row and updates fields', () async { await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg')]); - await seedChildren('a', 't1'); + final asset = _createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([asset]); + await seedExif(asset.id); - await sut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'renamed.jpg', isFavorite: true)]); + final renamed = _createAsset(id: asset.id, checksum: asset.checksum, fileName: 'renamed.jpg', isFavorite: true); + await sut.updateAssetsV1([renamed]); - expect(await countChildren('a'), 4, reason: 'DO UPDATE keeps the row, children survive'); - final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('a'))).getSingle(); - expect(row.name, 'renamed.jpg'); + 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 children', () async { - await pragmaOn('reupload'); + test('reupload with a new id replaces the stale row and cascades its child', () async { await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg')]); - await seedChildren('stale', 't2'); + final stale = _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([stale]); + await seedExif(stale.id); - await sut.updateAssetsV1([_createAsset(id: 'fresh', checksum: 'AAA', fileName: 'photo.jpg')]); + final fresh = _createAsset(id: 'fresh', checksum: stale.checksum, fileName: stale.originalFileName); + await sut.updateAssetsV1([fresh]); final rows = await db.remoteAssetEntity.select().get(); - expect(rows.map((r) => r.id), ['fresh'], reason: 'no 2067, stale row replaced away'); - expect(await countChildren('stale'), 0, reason: 'stale children cascade with the replaced row'); + 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 - await sut.updateAssetsV2([_createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg')]); - await seedChildren('stale2', 't2b'); - await sut.updateAssetsV2([_createAssetV2(id: 'fresh2', checksum: 'BBB', fileName: 'photo2.jpg')]); + 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), containsAllInOrder(['fresh', 'fresh2'])); - expect(await countChildren('stale2'), 0); + 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 pragmaOn('library'); await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), - _createAsset(id: 'keep-null', checksum: 'AAA', fileName: 'photo.jpg'), - ]); + 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]); - await sut.updateAssetsV1([ - _createAsset(id: 'fresh-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), - ]); + 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(), { - 'fresh-lib', - 'keep-null', + 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 pragmaOn('batch-dupes'); 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([ - _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'), - _createAsset(id: 'last-id', checksum: 'AAA', fileName: 'photo.jpg'), - _createAsset(id: 'first-lib', checksum: 'BBB', fileName: 'photo.jpg', libraryId: 'lib-1'), - _createAsset(id: 'last-lib', checksum: 'BBB', fileName: 'photo.jpg', libraryId: 'lib-1'), - ]); + 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', 'last-lib'}); - }); - - test('mixed batch of new, updated, and colliding assets all land', () async { - await pragmaOn('mixed'); - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'up', checksum: 'UUU', fileName: 'up.jpg'), - _createAsset(id: 'stale', checksum: 'CCC', fileName: 'collide.jpg'), - ]); - await seedChildren('up', 't5a'); - await seedChildren('stale', 't5b'); - - await sut.updateAssetsV1([ - _createAsset(id: 'up', checksum: 'UUU', fileName: 'up-renamed.jpg'), - _createAsset(id: 'new', checksum: 'NNN', fileName: 'new.jpg'), - _createAsset(id: 'fresh', checksum: 'CCC', fileName: 'collide.jpg'), - ]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows.map((r) => r.id).toSet(), {'up', 'new', 'fresh'}); - expect(rows.singleWhere((r) => r.id == 'up').name, 'up-renamed.jpg'); - expect(await countChildren('up'), 4, reason: 'in-place update keeps children'); - expect(await countChildren('stale'), 0, reason: 'collided row replaced and cascaded'); - }); - - test('a trashed stale row is replaced cleanly', () async { - await pragmaOn('trashed'); - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg', deletedAt: DateTime(2024, 2, 1)), - ]); - - await sut.updateAssetsV1([_createAsset(id: 'fresh', checksum: 'AAA', fileName: 'photo.jpg')]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows.map((r) => r.id), ['fresh']); - expect(rows.single.deletedAt, isNull, reason: 'payload deletedAt wins through REPLACE'); - }); - - test('fields survive a cross-id replace exactly as sent', () async { - await pragmaOn('fields'); - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg')]); - - await sut.updateAssetsV1([ - _createAsset( - id: 'fresh', - checksum: 'AAA', - fileName: 'rich.jpg', - isFavorite: true, - visibility: AssetVisibility.archive, - stackId: 'stack-1', - livePhotoVideoId: 'vid-1', - thumbhash: 'th-1', - width: 100, - height: 200, - fileCreatedAt: DateTime(2024, 5, 5), - fileModifiedAt: DateTime(2024, 6, 6), - ), - ]); - - final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('fresh'))).getSingle(); - expect(row.name, 'rich.jpg'); - expect(row.isFavorite, isTrue); - expect(row.visibility, domain.AssetVisibility.archive); - expect(row.stackId, 'stack-1'); - expect(row.livePhotoVideoId, 'vid-1'); - expect(row.thumbHash, 'th-1'); - expect(row.width, 100); - expect(row.height, 200); - expect(row.createdAt, DateTime(2024, 5, 5)); - expect(row.updatedAt, DateTime(2024, 6, 6)); - }); - - test('emits a single INSERT OR REPLACE statement with an id DO UPDATE arm', () async { - final captured = []; - final originalDebugPrint = drift.driftRuntimeOptions.debugPrint; - drift.driftRuntimeOptions.debugPrint = captured.add; - addTearDown(() => drift.driftRuntimeOptions.debugPrint = originalDebugPrint); - final logDb = Drift( - drift.DatabaseConnection(NativeDatabase.memory(logStatements: true), closeStreamsSynchronously: true), - ); - addTearDown(logDb.close); - final logSut = SyncStreamRepository(logDb); - - await logSut.updateUsersV1([_createUser()]); - await logSut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg')]); - - final lines = captured.where((l) => l.contains('remote_asset_entity')).toList(); - // ignore: avoid_print - print('RECEIPT emitted=$lines'); - expect(lines, isNotEmpty); - expect(lines.join('\n'), contains('INSERT OR REPLACE INTO "remote_asset_entity"')); - expect(lines.join('\n'), contains('ON CONFLICT("id") DO UPDATE')); - }); - - test('updateUsersV1 still upserts users', () async { - await sut.updateUsersV1([_createUser()]); - await sut.updateUsersV1([_createUser(name: 'Renamed User')]); - - final rows = await db.userEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.name, 'Renamed User'); + expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id}); }); }); }