|
| 1 | +// Run: pnpm exec vitest run src/main/ipc/cloudSyncUserKey.spec.ts |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 3 | +import type { ISettingConfig } from '../../types/globals' |
| 4 | + |
| 5 | +const mocks = vi.hoisted(() => ({ |
| 6 | + handlers: new Map<string, (event: unknown, payload?: unknown) => unknown>(), |
| 7 | + store: { settingConfig: {} as Partial<ISettingConfig>, songFingerprintList: [] }, |
| 8 | + is: { dev: false }, |
| 9 | + validate: vi.fn(), |
| 10 | + persist: vi.fn(), |
| 11 | + saveLibrary: vi.fn(), |
| 12 | + forgetJoin: vi.fn(), |
| 13 | + clearPrompt: vi.fn(), |
| 14 | + restartScheduler: vi.fn(), |
| 15 | + liveSync: vi.fn(), |
| 16 | + syncTick: vi.fn() |
| 17 | +})) |
| 18 | + |
| 19 | +vi.mock('electron', () => ({ |
| 20 | + ipcMain: { |
| 21 | + handle: (channel: string, handler: (event: unknown, payload?: unknown) => unknown) => { |
| 22 | + mocks.handlers.set(channel, handler) |
| 23 | + } |
| 24 | + }, |
| 25 | + BrowserWindow: { getAllWindows: () => [] } |
| 26 | +})) |
| 27 | +vi.mock('@electron-toolkit/utils', () => ({ is: mocks.is })) |
| 28 | +vi.mock('../store', () => ({ default: mocks.store })) |
| 29 | +vi.mock('../settingsPersistence', () => ({ persistSettingConfig: mocks.persist })) |
| 30 | +vi.mock('../librarySettingsDb', () => ({ |
| 31 | + isCuratedLibrarySyncEnabled: () => mocks.store.settingConfig.curatedLibrarySyncEnabled === true, |
| 32 | + saveLibrarySettingsFromConfig: mocks.saveLibrary, |
| 33 | + forgetCuratedLibrarySyncJoinState: mocks.forgetJoin |
| 34 | +})) |
| 35 | +vi.mock('../curatedLibrarySync/joinPrompt', () => ({ |
| 36 | + clearPendingCuratedLibraryJoinPrompt: mocks.clearPrompt |
| 37 | +})) |
| 38 | +vi.mock('../fetchWithSystemProxy', () => ({ fetchWithSystemProxy: mocks.validate })) |
| 39 | +vi.mock('../serverDiscovery', () => ({ resolveBaseUrl: async () => 'https://sync.test' })) |
| 40 | +vi.mock('../fingerprintStore', () => ({ |
| 41 | + getCollectionHashForSync: vi.fn(), |
| 42 | + unionFingerprintList: vi.fn() |
| 43 | +})) |
| 44 | +vi.mock('../log', () => ({ log: { error: vi.fn() } })) |
| 45 | +vi.mock('../curatedArtistCloudSync', () => ({ |
| 46 | + getCuratedArtistSyncErrorPayload: vi.fn(), |
| 47 | + isCuratedArtistSyncUnsupportedServer: vi.fn(), |
| 48 | + syncCuratedArtistCloudSnapshot: vi.fn() |
| 49 | +})) |
| 50 | +vi.mock('../cloudSyncScheduler', () => ({ |
| 51 | + bindCloudSyncScheduler: vi.fn(), |
| 52 | + restartCloudSyncScheduler: mocks.restartScheduler, |
| 53 | + runCuratedLibrarySyncTick: mocks.syncTick |
| 54 | +})) |
| 55 | +vi.mock('../curatedLibrarySync/liveSync', () => ({ syncCuratedLibraryLiveSync: mocks.liveSync })) |
| 56 | +vi.mock('../curatedLibrarySync/queue', () => { |
| 57 | + let queued: Promise<unknown> = Promise.resolve() |
| 58 | + return { |
| 59 | + enqueueCloudWork: <T>(task: () => Promise<T>): Promise<T> => { |
| 60 | + const run = queued.then(task, task) |
| 61 | + queued = run.catch(() => undefined) |
| 62 | + return run |
| 63 | + } |
| 64 | + } |
| 65 | +}) |
| 66 | +vi.mock('../window/mainWindow', () => ({ |
| 67 | + default: { instance: null }, |
| 68 | + syncWindowScreenshotShortcut: vi.fn() |
| 69 | +})) |
| 70 | +vi.mock('../bootstrap/settings', () => ({ |
| 71 | + applyThemeFromSettings: vi.fn(), |
| 72 | + broadcastSystemThemeIfNeeded: vi.fn() |
| 73 | +})) |
| 74 | +vi.mock('../platform/windowsContextMenu', () => ({ |
| 75 | + clearWindowsContextMenuSignature: vi.fn(), |
| 76 | + ensureWindowsContextMenuIfNeeded: vi.fn(), |
| 77 | + removeWindowsContextMenu: vi.fn() |
| 78 | +})) |
| 79 | +vi.mock('../menu/macMenu', () => ({ rebuildMacMenusForCurrentFocus: vi.fn() })) |
| 80 | +vi.mock('../curatedArtistLibrary', () => ({ |
| 81 | + clearCuratedArtistLibrary: vi.fn(), |
| 82 | + getCuratedArtistLibrarySnapshot: vi.fn(), |
| 83 | + importCuratedArtistsFromTracks: vi.fn(), |
| 84 | + replaceCuratedArtistLibrary: vi.fn(), |
| 85 | + removeCuratedArtist: vi.fn() |
| 86 | +})) |
| 87 | +vi.mock('../services/libraryMerge/runtime', () => ({ assertLibraryMergeMutationAllowed: vi.fn() })) |
| 88 | + |
| 89 | +import '../cloudSync' |
| 90 | +import { registerSettingsHandlers } from './settingsHandlers' |
| 91 | +import { DEV_DEFAULT_CLOUD_SYNC_USER_KEY } from '../../shared/cloudSyncDevUserKey' |
| 92 | + |
| 93 | +const invoke = async (channel: string, payload?: unknown) => { |
| 94 | + const handler = mocks.handlers.get(channel) |
| 95 | + if (!handler) throw new Error(`Missing IPC handler: ${channel}`) |
| 96 | + return await handler(undefined, payload) |
| 97 | +} |
| 98 | + |
| 99 | +const oldKey = '11111111-1111-4111-8111-111111111111' |
| 100 | +const newKey = '22222222-2222-4222-8222-222222222222' |
| 101 | + |
| 102 | +beforeEach(() => { |
| 103 | + vi.clearAllMocks() |
| 104 | + mocks.is.dev = false |
| 105 | + mocks.store.settingConfig = { |
| 106 | + cloudSyncUserKey: oldKey, |
| 107 | + curatedLibrarySyncEnabled: true, |
| 108 | + cloudSyncAutoEnabled: false |
| 109 | + } |
| 110 | + registerSettingsHandlers({ loadFingerprintList: async () => [] }) |
| 111 | + mocks.validate.mockImplementation(async (_url: string, init: { body: string }) => { |
| 112 | + const body = JSON.parse(init.body) as { userKey: string } |
| 113 | + return { |
| 114 | + status: 200, |
| 115 | + text: async () => |
| 116 | + JSON.stringify({ success: true, data: { isActive: true, userKey: body.userKey } }) |
| 117 | + } |
| 118 | + }) |
| 119 | +}) |
| 120 | + |
| 121 | +describe('cloud sync userKey lifecycle', () => { |
| 122 | + it.each([false, true])('关闭同步、换密钥、重新打开后保持新密钥 (dev=%s)', async (isDev) => { |
| 123 | + mocks.is.dev = isDev |
| 124 | + const staleRendererSettings = { ...mocks.store.settingConfig } |
| 125 | + await invoke('setSetting', { ...staleRendererSettings, curatedLibrarySyncEnabled: false }) |
| 126 | + expect(await invoke('cloudSync/config/save', { userKey: newKey })).toEqual({ |
| 127 | + success: true, |
| 128 | + userKey: newKey |
| 129 | + }) |
| 130 | + expect(await invoke('cloudSync/config/get')).toMatchObject({ userKey: newKey }) |
| 131 | + await invoke('setSetting', { ...staleRendererSettings, curatedLibrarySyncEnabled: true }) |
| 132 | + expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(newKey) |
| 133 | + expect(await invoke('cloudSync/config/get')).toMatchObject({ userKey: newKey }) |
| 134 | + expect(await invoke('cloudSync/config/save', { userKey: newKey })).toMatchObject({ |
| 135 | + success: true |
| 136 | + }) |
| 137 | + expect(mocks.forgetJoin).toHaveBeenCalledTimes(1) |
| 138 | + expect(mocks.clearPrompt).toHaveBeenCalledTimes(1) |
| 139 | + expect(mocks.syncTick).toHaveBeenCalledTimes(1) |
| 140 | + }) |
| 141 | + |
| 142 | + it('精选库同步开启时仍拦截更换密钥', async () => { |
| 143 | + expect(await invoke('cloudSync/config/save', { userKey: newKey })).toEqual({ |
| 144 | + success: false, |
| 145 | + message: 'cloudSync.curatedLibrary.errors.cannotChangeUserKey' |
| 146 | + }) |
| 147 | + expect(mocks.validate).not.toHaveBeenCalled() |
| 148 | + expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey) |
| 149 | + }) |
| 150 | + |
| 151 | + it('普通设置不能绕过校验更换或清除密钥', async () => { |
| 152 | + await invoke('setSetting', { ...mocks.store.settingConfig, cloudSyncUserKey: newKey }) |
| 153 | + expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey) |
| 154 | + await invoke('setSetting', { curatedLibrarySyncEnabled: false }) |
| 155 | + expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey) |
| 156 | + }) |
| 157 | + |
| 158 | + it('密钥校验失败时保留原密钥与对齐状态', async () => { |
| 159 | + mocks.store.settingConfig.curatedLibrarySyncEnabled = false |
| 160 | + mocks.validate.mockResolvedValue({ |
| 161 | + status: 200, |
| 162 | + text: async () => JSON.stringify({ success: false, error: 'INVALID_USER_KEY' }) |
| 163 | + }) |
| 164 | + expect(await invoke('cloudSync/config/save', { userKey: newKey })).toMatchObject({ |
| 165 | + success: false |
| 166 | + }) |
| 167 | + expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey) |
| 168 | + expect(mocks.forgetJoin).not.toHaveBeenCalled() |
| 169 | + }) |
| 170 | + |
| 171 | + it('保存服务端返回的规范密钥', async () => { |
| 172 | + mocks.store.settingConfig.curatedLibrarySyncEnabled = false |
| 173 | + mocks.validate.mockResolvedValue({ |
| 174 | + status: 200, |
| 175 | + text: async () => JSON.stringify({ success: true, data: { isActive: true, userKey: newKey } }) |
| 176 | + }) |
| 177 | + expect(await invoke('cloudSync/config/save', { userKey: 'input-key' })).toEqual({ |
| 178 | + success: true, |
| 179 | + userKey: newKey |
| 180 | + }) |
| 181 | + expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(newKey) |
| 182 | + }) |
| 183 | + |
| 184 | + it('开发模式仅在未配置密钥时提供默认值', async () => { |
| 185 | + mocks.is.dev = true |
| 186 | + mocks.store.settingConfig.cloudSyncUserKey = '' |
| 187 | + expect(await invoke('cloudSync/config/get')).toMatchObject({ |
| 188 | + userKey: DEV_DEFAULT_CLOUD_SYNC_USER_KEY |
| 189 | + }) |
| 190 | + }) |
| 191 | + |
| 192 | + it('正式模式未配置密钥时保持为空', async () => { |
| 193 | + mocks.store.settingConfig.cloudSyncUserKey = '' |
| 194 | + expect(await invoke('cloudSync/config/get')).toMatchObject({ userKey: '' }) |
| 195 | + }) |
| 196 | +}) |
0 commit comments