mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 07:32:32 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d918a65ef | |||
| 97430bec14 | |||
| 1120caca10 |
@@ -1,4 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.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/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
@@ -39,74 +40,99 @@ class SyncLinkedAlbumService {
|
|||||||
|
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
selectedAlbums.map((localAlbum) async {
|
selectedAlbums.map((localAlbum) async {
|
||||||
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
|
try {
|
||||||
if (linkedRemoteAlbumId == null) {
|
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
|
||||||
_log.warning("No linked remote album ID found for local album: ${localAlbum.name}");
|
if (linkedRemoteAlbumId == null) {
|
||||||
return;
|
_log.warning("No linked remote album ID found for local album: ${localAlbum.name}");
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
|
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
|
||||||
if (remoteAlbum == null) {
|
if (remoteAlbum == null) {
|
||||||
_log.warning("Linked remote album not found for ID: $linkedRemoteAlbumId");
|
_log.warning("Linked remote album not found for ID: $linkedRemoteAlbumId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get assets that are uploaded but not in the remote album
|
// get assets that are uploaded but not in the remote album
|
||||||
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
|
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
|
||||||
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
|
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
|
||||||
if (assetIds.isNotEmpty) {
|
if (assetIds.isNotEmpty) {
|
||||||
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
|
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
|
||||||
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
|
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
|
||||||
|
}
|
||||||
|
} on RemoteAlbumNotFoundException catch (e) {
|
||||||
|
// server doesn't have the linked album anymore. drop the cached row;
|
||||||
|
// KeyAction.setNull on LocalAlbumEntity.linkedRemoteAlbumId nulls
|
||||||
|
// the link via FK cascade, and the next manageLinkedAlbums run
|
||||||
|
// will recreate or re-link by name.
|
||||||
|
_log.warning(
|
||||||
|
"Pruning stale linked album for ${localAlbum.name} (server returned 'Album not found' for ${e.albumId})",
|
||||||
|
);
|
||||||
|
await _remoteAlbumRepository.deleteAlbum(e.albumId);
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.severe("Linked album sync failed for ${localAlbum.name}", error, stack);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
|
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
|
||||||
|
// fetch the server's authoritative owned-album list once and reconcile each
|
||||||
|
// local album against it. trusting only the local cache (previous behaviour)
|
||||||
|
// misses the case where the server lost an album that mobile still has
|
||||||
|
// cached (volume reset, soft-deleted user, etc).
|
||||||
|
final List<RemoteAlbum> serverAlbums;
|
||||||
|
try {
|
||||||
|
serverAlbums = await _albumApiRepository.getAllOwned(_storeService.get(StoreKey.currentUser));
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
// soft-fail on network / server error so a flaky link doesn't destroy local state
|
||||||
|
_log.severe("Could not fetch server albums; deferring manageLinkedAlbums", error, stackTrace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final serverById = {for (final a in serverAlbums) a.id: a};
|
||||||
|
final serverByName = {for (final a in serverAlbums) a.name: a};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (final album in localAlbums) {
|
for (final album in localAlbums) {
|
||||||
await _processLocalAlbum(album, ownerId);
|
await _processLocalAlbum(album, serverById, serverByName);
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
_log.severe("Error managing linked albums", error, stackTrace);
|
_log.severe("Error managing linked albums", error, stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a single local album to ensure proper linking with remote albums
|
/// Reconciles a single local album against the server's owned-album list.
|
||||||
Future<void> _processLocalAlbum(LocalAlbum localAlbum, String ownerId) {
|
Future<void> _processLocalAlbum(
|
||||||
final hasLinkedRemoteAlbum = localAlbum.linkedRemoteAlbumId != null;
|
LocalAlbum localAlbum,
|
||||||
|
Map<String, RemoteAlbum> serverById,
|
||||||
|
Map<String, RemoteAlbum> serverByName,
|
||||||
|
) async {
|
||||||
|
final linkedId = localAlbum.linkedRemoteAlbumId;
|
||||||
|
if (linkedId != null && serverById.containsKey(linkedId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (linkedId != null) {
|
||||||
|
// server doesn't have this album anymore. drop the cached row; KeyAction.setNull
|
||||||
|
// on LocalAlbumEntity.linkedRemoteAlbumId nulls the link via FK cascade.
|
||||||
|
await _remoteAlbumRepository.deleteAlbum(linkedId);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasLinkedRemoteAlbum) {
|
final byNameMatch = serverByName[localAlbum.name];
|
||||||
return _handleLinkedAlbum(localAlbum);
|
if (byNameMatch != null) {
|
||||||
|
await _linkToExistingRemoteAlbum(localAlbum, byNameMatch);
|
||||||
} else {
|
} else {
|
||||||
return _handleUnlinkedAlbum(localAlbum, ownerId);
|
await _createAndLinkNewRemoteAlbum(localAlbum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles albums that are already linked to a remote album
|
/// Links a local album to an existing remote album, ensuring the cache row exists
|
||||||
Future<void> _handleLinkedAlbum(LocalAlbum localAlbum) async {
|
/// so subsequent [syncLinkedAlbums] passes can find it without waiting for sync stream.
|
||||||
final remoteAlbumId = localAlbum.linkedRemoteAlbumId!;
|
Future<void> _linkToExistingRemoteAlbum(LocalAlbum localAlbum, RemoteAlbum existingRemoteAlbum) async {
|
||||||
final remoteAlbum = await _remoteAlbumRepository.get(remoteAlbumId);
|
final cached = await _remoteAlbumRepository.get(existingRemoteAlbum.id);
|
||||||
|
if (cached == null) {
|
||||||
final remoteAlbumExists = remoteAlbum != null;
|
await _remoteAlbumRepository.create(existingRemoteAlbum, []);
|
||||||
if (!remoteAlbumExists) {
|
|
||||||
return _localAlbumRepository.unlinkRemoteAlbum(localAlbum.id);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles albums that are not linked to any remote album
|
|
||||||
Future<void> _handleUnlinkedAlbum(LocalAlbum localAlbum, String ownerId) async {
|
|
||||||
final existingRemoteAlbum = await _remoteAlbumRepository.getByName(localAlbum.name, ownerId);
|
|
||||||
|
|
||||||
if (existingRemoteAlbum != null) {
|
|
||||||
return _linkToExistingRemoteAlbum(localAlbum, existingRemoteAlbum);
|
|
||||||
} else {
|
|
||||||
return _createAndLinkNewRemoteAlbum(localAlbum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Links a local album to an existing remote album
|
|
||||||
Future<void> _linkToExistingRemoteAlbum(LocalAlbum localAlbum, dynamic existingRemoteAlbum) {
|
|
||||||
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id);
|
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class DriftAlbumApiRepository extends ApiRepository {
|
|||||||
|
|
||||||
DriftAlbumApiRepository(this._api);
|
DriftAlbumApiRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<RemoteAlbum>> getAllOwned(UserDto owner) async {
|
||||||
|
final response = await checkNull(_api.getAllAlbums(isOwned: true));
|
||||||
|
return response.map((dto) => dto.toRemoteAlbum(owner)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
Future<RemoteAlbum> createDriftAlbum(
|
Future<RemoteAlbum> createDriftAlbum(
|
||||||
String name,
|
String name,
|
||||||
UserDto owner, {
|
UserDto owner, {
|
||||||
@@ -42,17 +47,24 @@ class DriftAlbumApiRepository extends ApiRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<({List<String> added, List<String> failed})> addAssets(String albumId, Iterable<String> assetIds) async {
|
Future<({List<String> added, List<String> failed})> addAssets(String albumId, Iterable<String> assetIds) async {
|
||||||
final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
|
try {
|
||||||
final List<String> added = [], failed = [];
|
final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
|
||||||
for (final dto in response) {
|
final List<String> added = [], failed = [];
|
||||||
if (dto.success) {
|
for (final dto in response) {
|
||||||
added.add(dto.id);
|
if (dto.success) {
|
||||||
} else {
|
added.add(dto.id);
|
||||||
failed.add(dto.id);
|
} else {
|
||||||
|
failed.add(dto.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (added: added, failed: failed);
|
return (added: added, failed: failed);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.code == 400 && (e.message?.contains('"message":"Album not found"') ?? false)) {
|
||||||
|
throw RemoteAlbumNotFoundException(albumId);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAlbum> updateAlbum(
|
Future<RemoteAlbum> updateAlbum(
|
||||||
@@ -104,6 +116,14 @@ class DriftAlbumApiRepository extends ApiRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RemoteAlbumNotFoundException implements Exception {
|
||||||
|
final String albumId;
|
||||||
|
const RemoteAlbumNotFoundException(this.albumId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'RemoteAlbumNotFoundException: $albumId';
|
||||||
|
}
|
||||||
|
|
||||||
extension on AlbumResponseDto {
|
extension on AlbumResponseDto {
|
||||||
RemoteAlbum toRemoteAlbum(final UserDto user) {
|
RemoteAlbum toRemoteAlbum(final UserDto user) {
|
||||||
return RemoteAlbum(
|
return RemoteAlbum(
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await _manageLinkedAlbums();
|
||||||
await ref.read(backgroundSyncProvider).syncLinkedAlbum();
|
await ref.read(backgroundSyncProvider).syncLinkedAlbum();
|
||||||
await ref.read(backgroundSyncProvider).syncRemote();
|
await ref.read(backgroundSyncProvider).syncRemote();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/foundation.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/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
import '../../fixtures/album.stub.dart';
|
||||||
|
import '../../fixtures/user.stub.dart';
|
||||||
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
|
||||||
|
RemoteAlbum _remoteAlbumFor(LocalAlbum local, {required String id}) => RemoteAlbum(
|
||||||
|
id: id,
|
||||||
|
name: local.name,
|
||||||
|
ownerId: UserStub.admin.id,
|
||||||
|
ownerName: UserStub.admin.name,
|
||||||
|
description: '',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
updatedAt: DateTime(2024),
|
||||||
|
isActivityEnabled: true,
|
||||||
|
order: AlbumAssetOrder.desc,
|
||||||
|
assetCount: 0,
|
||||||
|
isShared: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
LocalAlbum _localAlbum({required String id, required String name, String? linkedRemoteAlbumId}) => LocalAlbum(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
updatedAt: DateTime(2024),
|
||||||
|
assetCount: 5,
|
||||||
|
backupSelection: BackupSelection.selected,
|
||||||
|
isIosSharedAlbum: false,
|
||||||
|
linkedRemoteAlbumId: linkedRemoteAlbumId,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SyncLinkedAlbumService sut;
|
||||||
|
late MockLocalAlbumRepository mockLocalAlbumRepo;
|
||||||
|
late MockRemoteAlbumRepository mockRemoteAlbumRepo;
|
||||||
|
late MockDriftAlbumApiRepository mockAlbumApiRepo;
|
||||||
|
late Drift db;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||||
|
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||||
|
await Store.put(StoreKey.currentUser, UserStub.admin);
|
||||||
|
registerFallbackValue(LocalAlbumStub.recent);
|
||||||
|
registerFallbackValue(UserStub.admin);
|
||||||
|
registerFallbackValue(
|
||||||
|
RemoteAlbum(
|
||||||
|
id: 'fallback',
|
||||||
|
name: 'fallback',
|
||||||
|
ownerId: 'u',
|
||||||
|
ownerName: 'u',
|
||||||
|
description: '',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
updatedAt: DateTime(2024),
|
||||||
|
isActivityEnabled: true,
|
||||||
|
order: AlbumAssetOrder.desc,
|
||||||
|
assetCount: 0,
|
||||||
|
isShared: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
await Store.clear();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockLocalAlbumRepo = MockLocalAlbumRepository();
|
||||||
|
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
|
||||||
|
mockAlbumApiRepo = MockDriftAlbumApiRepository();
|
||||||
|
|
||||||
|
sut = SyncLinkedAlbumService(mockLocalAlbumRepo, mockRemoteAlbumRepo, mockAlbumApiRepo, StoreService.I);
|
||||||
|
|
||||||
|
when(() => mockLocalAlbumRepo.linkRemoteAlbum(any(), any())).thenAnswer((_) async {});
|
||||||
|
when(() => mockLocalAlbumRepo.unlinkRemoteAlbum(any())).thenAnswer((_) async {});
|
||||||
|
when(() => mockRemoteAlbumRepo.deleteAlbum(any())).thenAnswer((_) async {});
|
||||||
|
when(() => mockRemoteAlbumRepo.create(any(), any())).thenAnswer((_) async {});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('manageLinkedAlbums', () {
|
||||||
|
test('soft-fails when server fetch throws, no destructive writes', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies', linkedRemoteAlbumId: 'stale');
|
||||||
|
when(() => mockAlbumApiRepo.getAllOwned(any())).thenThrow(ApiException(503, 'down'));
|
||||||
|
|
||||||
|
await sut.manageLinkedAlbums([local], UserStub.admin.id);
|
||||||
|
|
||||||
|
verifyNever(() => mockRemoteAlbumRepo.deleteAlbum(any()));
|
||||||
|
verifyNever(() => mockLocalAlbumRepo.linkRemoteAlbum(any(), any()));
|
||||||
|
verifyNever(() => mockAlbumApiRepo.createDriftAlbum(any(), any(), assetIds: any(named: 'assetIds')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-op when linked album still exists on server', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies', linkedRemoteAlbumId: 'r1');
|
||||||
|
final remote = _remoteAlbumFor(local, id: 'r1');
|
||||||
|
when(() => mockAlbumApiRepo.getAllOwned(any())).thenAnswer((_) async => [remote]);
|
||||||
|
|
||||||
|
await sut.manageLinkedAlbums([local], UserStub.admin.id);
|
||||||
|
|
||||||
|
verifyNever(() => mockRemoteAlbumRepo.deleteAlbum(any()));
|
||||||
|
verifyNever(() => mockLocalAlbumRepo.linkRemoteAlbum(any(), any()));
|
||||||
|
verifyNever(() => mockAlbumApiRepo.createDriftAlbum(any(), any(), assetIds: any(named: 'assetIds')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prunes stale link when server no longer has the album', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies', linkedRemoteAlbumId: 'stale-id');
|
||||||
|
when(() => mockAlbumApiRepo.getAllOwned(any())).thenAnswer((_) async => []);
|
||||||
|
when(
|
||||||
|
() => mockAlbumApiRepo.createDriftAlbum(any(), any(), assetIds: any(named: 'assetIds')),
|
||||||
|
).thenAnswer((_) async => _remoteAlbumFor(local, id: 'new-id'));
|
||||||
|
|
||||||
|
await sut.manageLinkedAlbums([local], UserStub.admin.id);
|
||||||
|
|
||||||
|
verify(() => mockRemoteAlbumRepo.deleteAlbum('stale-id')).called(1);
|
||||||
|
verify(() => mockAlbumApiRepo.createDriftAlbum('Movies', UserStub.admin, assetIds: [])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links to existing server album by name when unlinked', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies');
|
||||||
|
final existing = _remoteAlbumFor(local, id: 'r-existing');
|
||||||
|
when(() => mockAlbumApiRepo.getAllOwned(any())).thenAnswer((_) async => [existing]);
|
||||||
|
when(() => mockRemoteAlbumRepo.get('r-existing')).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
await sut.manageLinkedAlbums([local], UserStub.admin.id);
|
||||||
|
|
||||||
|
verify(() => mockRemoteAlbumRepo.create(existing, [])).called(1);
|
||||||
|
verify(() => mockLocalAlbumRepo.linkRemoteAlbum('l1', 'r-existing')).called(1);
|
||||||
|
verifyNever(() => mockAlbumApiRepo.createDriftAlbum(any(), any(), assetIds: any(named: 'assetIds')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a new remote album when no match on server', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies');
|
||||||
|
final created = _remoteAlbumFor(local, id: 'r-new');
|
||||||
|
when(() => mockAlbumApiRepo.getAllOwned(any())).thenAnswer((_) async => []);
|
||||||
|
when(
|
||||||
|
() => mockAlbumApiRepo.createDriftAlbum(any(), any(), assetIds: any(named: 'assetIds')),
|
||||||
|
).thenAnswer((_) async => created);
|
||||||
|
|
||||||
|
await sut.manageLinkedAlbums([local], UserStub.admin.id);
|
||||||
|
|
||||||
|
verify(() => mockAlbumApiRepo.createDriftAlbum('Movies', UserStub.admin, assetIds: [])).called(1);
|
||||||
|
verify(() => mockRemoteAlbumRepo.create(created, [])).called(1);
|
||||||
|
verify(() => mockLocalAlbumRepo.linkRemoteAlbum('l1', 'r-new')).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('syncLinkedAlbums', () {
|
||||||
|
test('prunes cache row when addAssets throws RemoteAlbumNotFoundException', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies', linkedRemoteAlbumId: 'r-stale');
|
||||||
|
final remote = _remoteAlbumFor(local, id: 'r-stale');
|
||||||
|
when(() => mockLocalAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [local]);
|
||||||
|
when(() => mockRemoteAlbumRepo.get('r-stale')).thenAnswer((_) async => remote);
|
||||||
|
when(() => mockRemoteAlbumRepo.getLinkedAssetIds(any(), any(), any())).thenAnswer((_) async => ['a1']);
|
||||||
|
when(() => mockAlbumApiRepo.addAssets('r-stale', any())).thenThrow(const RemoteAlbumNotFoundException('r-stale'));
|
||||||
|
|
||||||
|
await sut.syncLinkedAlbums(UserStub.admin.id);
|
||||||
|
|
||||||
|
verify(() => mockRemoteAlbumRepo.deleteAlbum('r-stale')).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips albums with null linked id without server calls', () async {
|
||||||
|
final local = _localAlbum(id: 'l1', name: 'Movies');
|
||||||
|
when(() => mockLocalAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [local]);
|
||||||
|
|
||||||
|
await sut.syncLinkedAlbums(UserStub.admin.id);
|
||||||
|
|
||||||
|
verifyNever(() => mockAlbumApiRepo.addAssets(any(), any()));
|
||||||
|
verifyNever(() => mockRemoteAlbumRepo.deleteAlbum(any()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user