refactor to per row store

This commit is contained in:
shenlong-tanwen
2026-04-29 00:43:52 +07:00
parent cc56f86333
commit 5933664862
22 changed files with 338 additions and 266 deletions
@@ -4,7 +4,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/config/log_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_kind.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:logging/logging.dart';
@@ -30,21 +30,20 @@ final _kWarnLog = LogMessage(
void main() {
late LogService sut;
late LogRepository mockLogRepo;
late MockCachedMetadataRepository mockMetadataRepository;
late MockMetadataRepository mockMetadataRepository;
setUp(() async {
mockLogRepo = MockLogRepository();
mockMetadataRepository = MockCachedMetadataRepository();
mockMetadataRepository = MockMetadataRepository();
registerFallbackValue(_kInfoLog);
SystemConfig identityMutator(SystemConfig c) => c;
registerFallbackValue(identityMutator);
registerFallbackValue(LogLevel.info);
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
when(
() => mockMetadataRepository.read(MetadataKind.systemConfig),
() => mockMetadataRepository.systemConfig,
).thenReturn(const SystemConfig(log: LogConfig(level: LogLevel.fine)));
when(() => mockMetadataRepository.update<SystemConfig>(MetadataKind.systemConfig, any())).thenAnswer((_) async {});
when(() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
@@ -63,7 +62,7 @@ void main() {
});
test('Sets log level based on the metadata repository', () {
verify(() => mockMetadataRepository.read(MetadataKind.systemConfig)).called(1);
verify(() => mockMetadataRepository.systemConfig).called(1);
expect(Logger.root.level, Level.FINE);
});
});
@@ -74,13 +73,10 @@ void main() {
});
test('Updates the log level via metadata repository', () {
final mutator =
verify(
() => mockMetadataRepository.update<SystemConfig>(MetadataKind.systemConfig, captureAny()),
).captured.firstOrNull
as SystemConfig Function(SystemConfig)?;
final result = mutator?.call(const SystemConfig());
expect(result?.log.level, LogLevel.shout);
final captured = verify(
() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, captureAny()),
).captured.firstOrNull;
expect(captured, LogLevel.shout);
});
test('Sets log level on logger', () {
@@ -1,8 +1,8 @@
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
@@ -18,7 +18,7 @@ import 'package:mocktail/mocktail.dart';
class MockDriftStoreRepository extends Mock implements DriftStoreRepository {}
class MockCachedMetadataRepository extends Mock implements CachedMetadataRepository {}
class MockMetadataRepository extends Mock implements MetadataRepository {}
class MockLogRepository extends Mock implements LogRepository {}
@@ -0,0 +1,153 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late MetadataRepository sut;
setUpAll(() async {
ctx = MediumRepositoryContext();
sut = await MetadataRepository.ensureInitialized(ctx.db);
});
tearDownAll(() async {
await ctx.dispose();
});
setUp(() async {
await ctx.db.delete(ctx.db.metadataEntity).go();
await MetadataRepository.refresh();
});
group('defaults', () {
test('appConfig returns key defaults when DB is empty', () {
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
test('systemConfig returns key defaults when DB is empty', () {
expect(sut.systemConfig.log.level, LogLevel.info);
});
});
group('write', () {
test('persists a value and reflects it in the composed view', () async {
await sut.write(.themeMode, ThemeMode.dark);
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
test('persists across domains independently', () async {
await sut.write(.themeMode, ThemeMode.light);
await sut.write(.logLevel, LogLevel.severe);
expect(sut.appConfig.theme.mode, ThemeMode.light);
expect(sut.systemConfig.log.level, LogLevel.severe);
});
});
group('delete', () {
test('removes the row and reverts to default', () async {
await sut.write(.themeMode, ThemeMode.dark);
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await sut.delete(.themeMode);
expect(sut.appConfig.theme.mode, ThemeMode.system);
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
expect(rows, isEmpty);
});
});
group('clearDomain', () {
test('clears every key in the domain and leaves other domains alone', () async {
await sut.write(.themeMode, ThemeMode.dark);
await sut.write(.logLevel, LogLevel.severe);
await sut.clearDomain(.appConfig);
expect(sut.appConfig.theme.mode, ThemeMode.system);
expect(sut.systemConfig.log.level, LogLevel.severe);
final remainingKeys = (await ctx.db.select(ctx.db.metadataEntity).get()).map((r) => r.key);
expect(remainingKeys, [MetadataKey.logLevel.key]);
});
});
group('refresh', () {
test('picks up rows that were inserted directly into the DB', () async {
await ctx.db
.into(ctx.db.metadataEntity)
.insert(
MetadataEntityCompanion.insert(
key: MetadataKey.themeMode.key,
value: ThemeMode.dark.name,
updatedAt: Value(DateTime.now()),
),
);
// Cache hasn't seen this row yet — view still returns the default.
expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
test('drops cached values for rows that were deleted out from under the repo', () async {
await sut.write(.themeMode, ThemeMode.dark);
// Wipe the row directly. Cache still holds the old value.
await ctx.db.delete(ctx.db.metadataEntity).go();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
test('skips rows whose key is unknown to MetadataKey', () async {
await ctx.db
.into(ctx.db.metadataEntity)
.insert(
MetadataEntityCompanion.insert(
key: 'app-config.unknown.future-key',
value: 'whatever',
updatedAt: Value(DateTime.now()),
),
);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
});
group('watch', () {
test('watchAppConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
await expectation;
});
test('watchAppConfig does not emit when only system-config rows change', () async {
final emissions = <ThemeMode>[];
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
await sut.write(MetadataKey.logLevel, LogLevel.severe);
await pumpEventQueue();
await sub.cancel();
expect(emissions, isEmpty);
});
test('watchSystemConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.log.level), emitsThrough(LogLevel.warning));
await sut.write(MetadataKey.logLevel, LogLevel.warning);
await expectation;
});
});
}
+7 -2
View File
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
@@ -24,7 +25,7 @@ void main() {
late MockNetworkService networkService;
late MockBackgroundSyncManager backgroundSyncManager;
late MockAppSettingService appSettingsService;
late MockCachedMetadataRepository metadataRepository;
late MockMetadataRepository metadataRepository;
late Drift db;
setUp(() async {
@@ -34,7 +35,7 @@ void main() {
networkService = MockNetworkService();
backgroundSyncManager = MockBackgroundSyncManager();
appSettingsService = MockAppSettingService();
metadataRepository = MockCachedMetadataRepository();
metadataRepository = MockMetadataRepository();
sut = AuthService(
authApiRepository,
@@ -114,6 +115,10 @@ void main() {
});
group('logout', () {
setUp(() {
when(() => metadataRepository.clearDomain(MetadataDomain.appConfig)).thenAnswer((_) async {});
});
test('Should logout user', () async {
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});