diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2c3e853ab..ddce20e55 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 3.3.3-local.3+3030402 +version: 3.3.3-local.4+3030403 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart index 15d42c34f..65d8b5a2b 100644 --- a/mobile/test/modules/utils/openapi_patching_test.dart +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -6,6 +6,51 @@ import 'package:openapi/api.dart'; void main() { group('Test OpenApi Patching', () { + test('older public config deserializes with map and video defaults', () { + final config = PublicConfigDto.fromJson({ + 'oauth': {'autoLaunch': false, 'buttonText': 'Login', 'enabled': false}, + 'passwordLogin': {'enabled': true}, + 'server': {'loginPageMessage': ''}, + }); + expect(config, isNotNull); + expect(config!.map.enabled, isTrue); + expect(config.map.lightStyle, 'https://tiles.immich.cloud/v1/style/light.json'); + expect(config.ffmpeg.realtime.enabled, isFalse); + }); + + test('public config keeps explicit server map and video settings', () { + final config = PublicConfigDto.fromJson({ + 'oauth': {'autoLaunch': false, 'buttonText': 'Login', 'enabled': false}, + 'passwordLogin': {'enabled': true}, + 'server': {'loginPageMessage': ''}, + 'map': {'enabled': false, 'lightStyle': 'https://example.com/light', 'darkStyle': 'https://example.com/dark'}, + 'ffmpeg': { + 'realtime': {'enabled': true}, + }, + }); + expect(config!.map.enabled, isFalse); + expect(config.map.lightStyle, 'https://example.com/light'); + expect(config.ffmpeg.realtime.enabled, isTrue); + }); + + test('older user config defaults SMTP to disabled', () { + final value = {}; + upgradeDto(value, 'UserConfigDto'); + final notifications = UserConfigNotificationsDto.fromJson(value['notifications']); + expect(notifications!.smtp.enabled, isFalse); + }); + + test('user config preserves an enabled SMTP setting', () { + final value = { + 'notifications': { + 'smtp': {'enabled': true}, + }, + }; + upgradeDto(value, 'UserConfigDto'); + final notifications = UserConfigNotificationsDto.fromJson(value['notifications']); + expect(notifications!.smtp.enabled, isTrue); + }); + test('upgradeDto', () { dynamic value; String targetType; diff --git a/web/src/lib/queries/user-admin.query.spec.ts b/web/src/lib/queries/user-admin.query.spec.ts new file mode 100644 index 000000000..a34e5be5a --- /dev/null +++ b/web/src/lib/queries/user-admin.query.spec.ts @@ -0,0 +1,105 @@ +import { + createUserAdmin, + deleteUserAdmin, + restoreUserAdmin, + searchUsersAdmin, + updateUserAdmin, + type UserAdminResponseDto, +} from '@immich/sdk'; +import { QueryObserver } from '@tanstack/svelte-query'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { executeMutation, queryClient } from '$lib/queries/client'; +import { + createUserAdminMutation, + deleteUserAdminMutation, + getUserAdminQuery, + restoreUserAdminMutation, + searchUsersAdminQuery, + updateUserAdminMutation, +} from '$lib/queries/user-admin.query'; +import { handleError } from '$lib/utils/handle-error'; + +vi.mock('$lib', () => ({ getHeatmapRange: vi.fn() })); +vi.mock('@immich/ui', () => ({ toastManager: { primary: vi.fn(), info: vi.fn() } })); +vi.mock('$lib/utils/handle-error', () => ({ handleError: vi.fn() })); +vi.mock('$lib/utils/i18n', () => ({ getFormatter: () => Promise.resolve((key: string) => key) })); +vi.mock('@immich/sdk', () => ({ + CalendarHeatmapType: { Upload: 'upload' }, + createUserAdmin: vi.fn(), + deleteUserAdmin: vi.fn(), + restoreUserAdmin: vi.fn(), + updateUserAdmin: vi.fn(), + searchUsersAdmin: vi.fn(), + getUserCalendarHeatmapAdmin: vi.fn(), + getUserPreferencesAdmin: vi.fn(), + getUserSessionsAdmin: vi.fn(), + getUserStatisticsAdmin: vi.fn(), +})); + +const active = { id: 'active', name: 'Active', deletedAt: null } as UserAdminResponseDto; +const deleted = { id: 'deleted', name: 'Deleted', deletedAt: '2026-10-16T00:00:00Z' } as UserAdminResponseDto; + +describe('admin user query regressions', () => { + beforeEach(() => { + queryClient.clear(); + vi.resetAllMocks(); + }); + afterEach(() => queryClient.clear()); + + it('shares one request while filtering deleted users only for active-user consumers', async () => { + vi.mocked(searchUsersAdmin).mockResolvedValue([active, deleted]); + await Promise.all([queryClient.query(searchUsersAdminQuery()), queryClient.query(getUserAdminQuery(deleted.id))]); + const activeUsers = new QueryObserver(queryClient, searchUsersAdminQuery()); + const allUsers = new QueryObserver(queryClient, searchUsersAdminQuery({ withDeleted: true })); + const detail = new QueryObserver(queryClient, getUserAdminQuery(deleted.id)); + expect(searchUsersAdmin).toHaveBeenCalledTimes(1); + expect(activeUsers.getCurrentResult().data).toEqual([active]); + expect(allUsers.getCurrentResult().data).toEqual([active, deleted]); + expect(detail.getCurrentResult().data).toEqual(deleted); + activeUsers.destroy(); + allUsers.destroy(); + detail.destroy(); + }); + + it('preserves the user list and reports an error when deletion fails', async () => { + queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]); + vi.mocked(deleteUserAdmin).mockRejectedValue(new Error('offline')); + await executeMutation(deleteUserAdminMutation, [{ id: active.id }]); + expect(queryClient.getQueryData(searchUsersAdminQuery().queryKey)).toEqual([active, deleted]); + expect(handleError).toHaveBeenCalledOnce(); + }); + + it('uses the server response for edits without changing another user', async () => { + queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]); + const saved = { ...active, name: 'Saved', quotaSizeInBytes: 1024 }; + vi.mocked(updateUserAdmin).mockResolvedValue(saved); + await executeMutation(updateUserAdminMutation, [{ id: active.id, userAdminUpdateDto: { name: saved.name } }]); + expect(queryClient.getQueryData(searchUsersAdminQuery().queryKey)).toEqual([saved, deleted]); + }); + + it('makes a restored account available to active-user consumers', async () => { + queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]); + const restored = { ...deleted, deletedAt: null }; + vi.mocked(restoreUserAdmin).mockResolvedValue(restored); + await executeMutation(restoreUserAdminMutation, [{ id: deleted.id }]); + const observer = new QueryObserver(queryClient, searchUsersAdminQuery()); + expect(observer.getCurrentResult().data).toEqual([active, restored]); + observer.destroy(); + }); + + it('refreshes the user list after account creation', async () => { + queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active]); + vi.mocked(createUserAdmin).mockResolvedValue(deleted); + await executeMutation(createUserAdminMutation, [ + { userAdminCreateDto: { email: 'test@example.com', name: 'Test', password: 'test-only-password' } }, + ]); + expect(queryClient.getQueryState(searchUsersAdminQuery().queryKey)?.isInvalidated).toBe(true); + }); + + it('clears admin user data on logout', () => { + queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]); + queryClient.setQueryData(['user-admin', active.id, 'sessions'], [{ id: 'session' }]); + eventManager.emit('AuthLogout'); + expect(queryClient.getQueryCache().getAll()).toHaveLength(0); + }); +});