Files
immich/mobile/test/services/action.service_test.dart
T
Santo Shakil 9cb94343d1 fix(mobile): keep timezone when editing asset date time (#28978)
* fix(mobile): keep timezone when editing asset date time

* fix(mobile): negative utc offsets with minutes off by an hour
2026-06-10 21:31:31 -05:00

191 lines
7.4 KiB
Dart

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/store.model.dart';
import 'package:immich_mobile/domain/services/store.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/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class MockDownloadRepository extends Mock implements DownloadRepository {}
void main() {
late ActionService sut;
late MockAssetApiRepository assetApiRepository;
late MockRemoteAssetRepository remoteAssetRepository;
late MockDriftLocalAssetRepository localAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
setUp(() {
assetApiRepository = MockAssetApiRepository();
remoteAssetRepository = MockRemoteAssetRepository();
localAssetRepository = MockDriftLocalAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
sut = ActionService(
assetApiRepository,
remoteAssetRepository,
localAssetRepository,
albumApiRepository,
remoteAlbumRepository,
trashedLocalAssetRepository,
assetMediaRepository,
downloadRepository,
tagService,
);
});
tearDown(() async {
await Store.clear();
});
group('ActionService.updateRating', () {
const assetId = 'asset_id_1';
test('calls both repositories with the given rating', () async {
when(() => assetApiRepository.updateRating(assetId, 3)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateRating(assetId, 3)).thenAnswer((_) async {});
final result = await sut.updateRating(assetId, 3);
expect(result, isTrue);
verify(() => assetApiRepository.updateRating(assetId, 3)).called(1);
verify(() => remoteAssetRepository.updateRating(assetId, 3)).called(1);
});
test('calls both repositories with null to clear rating', () async {
when(() => assetApiRepository.updateRating(assetId, null)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateRating(assetId, null)).thenAnswer((_) async {});
final result = await sut.updateRating(assetId, null);
expect(result, isTrue);
verify(() => assetApiRepository.updateRating(assetId, null)).called(1);
verify(() => remoteAssetRepository.updateRating(assetId, null)).called(1);
});
});
group('ActionService.applyDateTime', () {
const ids = ['asset_id_1'];
test('sends the picked value to the api with its offset intact', () async {
const picked = '2026-06-10T19:15:00.000+06:00';
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
when(
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00'),
).thenAnswer((_) async {});
await sut.applyDateTime(ids, picked);
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00')).called(1);
});
test('handles negative offsets', () async {
const picked = '2026-01-05T08:00:00.000-05:30';
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
when(
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30'),
).thenAnswer((_) async {});
await sut.applyDateTime(ids, picked);
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30')).called(1);
});
test('writes no timezone when the value has no offset', () async {
const picked = '2026-06-10T13:15:00.000Z';
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
when(
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null),
).thenAnswer((_) async {});
await sut.applyDateTime(ids, picked);
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1);
});
});
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('short-circuits when nothing was deleted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
final result = await sut.deleteLocal(ids);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
});
}