From ed2a42ad6b8dd545b295414a0d19eb17d5566a39 Mon Sep 17 00:00:00 2001 From: Misaka Date: Sat, 21 Mar 2026 18:26:13 +0800 Subject: [PATCH] test: cover electron boundary modules --- tests/unit/auth-handler.test.ts | 86 +++++++++ tests/unit/bootstrap-runtime.test.ts | 95 ++++++++++ tests/unit/cleaner-handler.test.ts | 75 ++++++++ tests/unit/update-catalog-service.test.ts | 136 ++++++++++++++ tests/unit/update-installer.test.ts | 33 ++++ tests/unit/update-service.test.ts | 196 +++++++++++++++++++++ tests/unit/update-status-publisher.test.ts | 36 ++++ 7 files changed, 657 insertions(+) create mode 100644 tests/unit/auth-handler.test.ts create mode 100644 tests/unit/bootstrap-runtime.test.ts create mode 100644 tests/unit/cleaner-handler.test.ts create mode 100644 tests/unit/update-catalog-service.test.ts create mode 100644 tests/unit/update-installer.test.ts create mode 100644 tests/unit/update-service.test.ts create mode 100644 tests/unit/update-status-publisher.test.ts diff --git a/tests/unit/auth-handler.test.ts b/tests/unit/auth-handler.test.ts new file mode 100644 index 0000000..ceb1ac8 --- /dev/null +++ b/tests/unit/auth-handler.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IPC_CHANNELS } from '../../src/shared/ipc-channels' + +const handleMock = vi.fn() +const withErrorHandlingMock = vi.fn(async (handler: () => Promise) => { + const data = await handler() + return { success: true, data } +}) + +const serviceMethods = { + getComputerName: vi.fn(async () => 'TEST-PC'), + silentLogin: vi.fn(async () => ({ success: true })), + login: vi.fn(async (username: string) => ({ success: true, username })), + logout: vi.fn(async () => undefined), + getCurrentUser: vi.fn(async () => ({ success: true })), + getAllUsers: vi.fn(async () => []), + switchUser: vi.fn(async (user: unknown) => ({ success: true, user })), + isAdmin: vi.fn(async () => true) +} + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('../../src/main/ipc/index', () => ({ + withErrorHandling: withErrorHandlingMock +})) + +vi.mock('../../src/main/services/auth/auth-application-service', () => ({ + AuthApplicationService: class { + getComputerName = serviceMethods.getComputerName + silentLogin = serviceMethods.silentLogin + login = serviceMethods.login + logout = serviceMethods.logout + getCurrentUser = serviceMethods.getCurrentUser + getAllUsers = serviceMethods.getAllUsers + switchUser = serviceMethods.switchUser + isAdmin = serviceMethods.isAdmin + } +})) + +describe('registerAuthHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers auth IPC handlers and delegates login requests', async () => { + const { registerAuthHandlers } = await import('../../src/main/ipc/auth-handler') + + registerAuthHandlers() + + expect(handleMock).toHaveBeenCalledWith(IPC_CHANNELS.AUTH_LOGIN, expect.any(Function)) + expect(handleMock).toHaveBeenCalledWith(IPC_CHANNELS.AUTH_SILENT_LOGIN, expect.any(Function)) + expect(handleMock).toHaveBeenCalledTimes(8) + + const loginHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC_CHANNELS.AUTH_LOGIN + )?.[1] + + const result = await loginHandler?.({}, { username: 'alice', password: 'secret' }) + + expect(serviceMethods.login).toHaveBeenCalledWith('alice', 'secret') + expect(withErrorHandlingMock).toHaveBeenCalled() + expect(result).toEqual({ + success: true, + data: { success: true, username: 'alice' } + }) + }) + + it('delegates switch user requests through the service layer', async () => { + const { registerAuthHandlers } = await import('../../src/main/ipc/auth-handler') + const userInfo = { id: 1, username: 'admin' } + + registerAuthHandlers() + + const switchUserHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC_CHANNELS.AUTH_SWITCH_USER + )?.[1] + + await switchUserHandler?.({}, userInfo) + + expect(serviceMethods.switchUser).toHaveBeenCalledWith(userInfo) + }) +}) diff --git a/tests/unit/bootstrap-runtime.test.ts b/tests/unit/bootstrap-runtime.test.ts new file mode 100644 index 0000000..9fac53a --- /dev/null +++ b/tests/unit/bootstrap-runtime.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'path' + +const mkdirSyncMock = vi.fn() +const existsSyncMock = vi.fn() +const readdirSyncMock = vi.fn() +const showErrorBoxMock = vi.fn() +const setAppUserModelIdMock = vi.fn() +const appOnMock = vi.fn() +const configInitializeMock = vi.fn(async () => undefined) +const updateInitializeMock = vi.fn() +const registerIpcHandlersMock = vi.fn() + +vi.mock('fs', () => ({ + default: { + mkdirSync: mkdirSyncMock, + existsSync: existsSyncMock, + readdirSync: readdirSyncMock + }, + mkdirSync: mkdirSyncMock, + existsSync: existsSyncMock, + readdirSync: readdirSyncMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => 'D:/userData'), + setAppUserModelId: setAppUserModelIdMock, + on: appOnMock, + isPackaged: false + }, + dialog: { + showErrorBox: showErrorBoxMock + } +})) + +vi.mock('../../src/main/services/config/config-manager', () => ({ + ConfigManager: { + getInstance: vi.fn(() => ({ + initialize: configInitializeMock + })) + } +})) + +vi.mock('../../src/main/services/update/update-service', () => ({ + UpdateService: { + getInstance: vi.fn(() => ({ + initialize: updateInitializeMock + })) + } +})) + +vi.mock('../../src/main/ipc', () => ({ + registerIpcHandlers: registerIpcHandlersMock +})) + +describe('bootstrap runtime', () => { + beforeEach(() => { + vi.clearAllMocks() + existsSyncMock.mockReset() + readdirSyncMock.mockReset() + }) + + it('configures Playwright browser path under app userData', async () => { + const { configurePlaywrightBrowsersPath } = await import('../../src/main/bootstrap/runtime') + + const result = configurePlaywrightBrowsersPath() + + const expectedPath = join('D:/userData', 'ms-playwright') + expect(result).toBe(expectedPath) + expect(process.env.PLAYWRIGHT_BROWSERS_PATH).toBe(expectedPath) + }) + + it('initializes config, update service and IPC registration', async () => { + const { initializeMainProcessServices } = await import('../../src/main/bootstrap/runtime') + + await initializeMainProcessServices() + + expect(configInitializeMock).toHaveBeenCalledTimes(1) + expect(updateInitializeMock).toHaveBeenCalledTimes(1) + expect(registerIpcHandlersMock).toHaveBeenCalledTimes(1) + }) + + it('shows an error dialog when no Playwright browser is found', async () => { + const { ensurePlaywrightRuntime } = await import('../../src/main/bootstrap/runtime') + + existsSyncMock.mockReturnValue(false) + readdirSyncMock.mockReturnValue([]) + + ensurePlaywrightRuntime('D:/userData/ms-playwright') + + expect(mkdirSyncMock).toHaveBeenCalledWith('D:/userData/ms-playwright', { recursive: true }) + expect(showErrorBoxMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/cleaner-handler.test.ts b/tests/unit/cleaner-handler.test.ts new file mode 100644 index 0000000..ee21725 --- /dev/null +++ b/tests/unit/cleaner-handler.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IPC_CHANNELS } from '../../src/shared/ipc-channels' + +const handleMock = vi.fn() +const withErrorHandlingMock = vi.fn(async (handler: () => Promise) => { + const data = await handler() + return { success: true, data } +}) + +const serviceMethods = { + runCleaner: vi.fn(async (_sender: unknown, input: unknown) => ({ success: true, input })), + exportResults: vi.fn(async (items: unknown[]) => ({ success: true, total: items.length })) +} + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('../../src/main/ipc/index', () => ({ + withErrorHandling: withErrorHandlingMock +})) + +vi.mock('../../src/main/services/cleaner/cleaner-application-service', () => ({ + CleanerApplicationService: class { + runCleaner = serviceMethods.runCleaner + exportResults = serviceMethods.exportResults + } +})) + +describe('registerCleanerHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers cleaner IPC handlers and forwards the sender to the application service', async () => { + const { registerCleanerHandlers } = await import('../../src/main/ipc/cleaner-handler') + const sender = { id: 99 } + const event = { sender } + const input = { orderNumbers: ['MO-001'] } + + registerCleanerHandlers() + + expect(handleMock).toHaveBeenCalledTimes(2) + expect(handleMock).toHaveBeenCalledWith(IPC_CHANNELS.CLEANER_RUN, expect.any(Function)) + + const runHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC_CHANNELS.CLEANER_RUN + )?.[1] + + const result = await runHandler?.(event, input) + + expect(serviceMethods.runCleaner).toHaveBeenCalledWith(sender, input) + expect(result).toEqual({ + success: true, + data: { success: true, input } + }) + }) + + it('forwards export requests to the cleaner application service', async () => { + const { registerCleanerHandlers } = await import('../../src/main/ipc/cleaner-handler') + const items = [{ id: '1' }, { id: '2' }] + + registerCleanerHandlers() + + const exportHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC_CHANNELS.CLEANER_EXPORT_RESULTS + )?.[1] + + await exportHandler?.({}, items) + + expect(serviceMethods.exportResults).toHaveBeenCalledWith(items) + }) +}) diff --git a/tests/unit/update-catalog-service.test.ts b/tests/unit/update-catalog-service.test.ts new file mode 100644 index 0000000..c8f445e --- /dev/null +++ b/tests/unit/update-catalog-service.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest' +import { UpdateCatalogService } from '../../src/main/services/update/update-catalog-service' +import type { UpdateConfig } from '../../src/main/types/config.schema' +import type { UpdateCatalog, UpdateRelease, UpdateStatus } from '../../src/main/types/update.types' + +const config: UpdateConfig = { + enabled: true, + allowDevMode: false, + endpoint: 'http://localhost:9000', + accessKey: 'key', + secretKey: 'secret', + bucket: 'bucket', + region: 'us-east-1', + basePrefix: 'updates/win-portable', + checkIntervalMinutes: 30, + maxAdminHistoryPerChannel: 2 +} + +function createRelease( + version: string, + channel: 'stable' | 'preview', + overrides: Partial = {} +): UpdateRelease { + return { + version, + channel, + artifactKey: `${channel}/${version}.exe`, + sha256: `${channel}-${version}-sha`, + size: 1, + publishedAt: '2026-03-21T10:00:00Z', + changelogKey: `${channel}/${version}.md`, + ...overrides + } +} + +function createStatus(overrides: Partial = {}): UpdateStatus { + return { + enabled: true, + supported: true, + phase: 'idle', + currentVersion: '1.0.0', + currentChannel: 'stable', + currentUserType: 'User', + ...overrides + } +} + +describe('UpdateCatalogService', () => { + it('returns disabled dialog catalog when updates are unavailable', () => { + const storageClient = { readText: vi.fn() } + const installer = { getValidDownloadedRelease: vi.fn() } + const service = new UpdateCatalogService(config, storageClient as never, installer as never) + + const result = service.getDialogCatalog( + createStatus({ enabled: false, currentUserType: null }), + { stable: [], preview: [] } + ) + + expect(result).toEqual({ mode: 'disabled' }) + }) + + it('loads stable and preview catalogs for admin users', async () => { + const storageClient = { + readText: vi + .fn() + .mockResolvedValueOnce(JSON.stringify({ releases: [createRelease('1.1.0', 'stable')] })) + .mockResolvedValueOnce(JSON.stringify({ releases: [createRelease('1.2.0', 'preview')] })) + } + const installer = { getValidDownloadedRelease: vi.fn() } + const service = new UpdateCatalogService(config, storageClient as never, installer as never) + + const result = await service.loadCatalog('Admin') + + expect(storageClient.readText).toHaveBeenCalledTimes(2) + expect(result.stable[0]?.version).toBe('1.1.0') + expect(result.preview[0]?.version).toBe('1.2.0') + }) + + it('marks user update as downloaded when a verified package already exists', async () => { + const recommended = createRelease('1.1.0', 'stable') + const storageClient = { readText: vi.fn() } + const installer = { + getValidDownloadedRelease: vi.fn().mockResolvedValue({ + ...recommended, + localPath: 'D:/downloads/stable-1.1.0.exe' + }) + } + const service = new UpdateCatalogService(config, storageClient as never, installer as never) + + const result = await service.resolveUserStatus(createStatus(), { + stable: [recommended], + preview: [] + }) + + expect(result.phase).toBe('downloaded') + expect(result.downloadedRelease?.localPath).toContain('stable-1.1.0.exe') + expect(result.recommendedRelease?.version).toBe('1.1.0') + }) + + it('returns available admin status and trims catalog history', () => { + const storageClient = { readText: vi.fn() } + const installer = { getValidDownloadedRelease: vi.fn() } + const service = new UpdateCatalogService(config, storageClient as never, installer as never) + const catalog: UpdateCatalog = { + stable: [ + createRelease('1.0.0', 'stable'), + createRelease('1.1.0', 'stable'), + createRelease('1.2.0', 'stable') + ], + preview: [ + createRelease('1.3.0', 'preview'), + createRelease('1.4.0', 'preview'), + createRelease('1.5.0', 'preview') + ] + } + + const adminStatus = service.resolveAdminStatus( + createStatus({ currentUserType: 'Admin', currentVersion: '1.0.0' }), + catalog + ) + const dialogCatalog = service.getDialogCatalog( + createStatus({ + currentUserType: 'Admin', + currentVersion: '1.0.0', + recommendedRelease: createRelease('1.5.0', 'preview') + }), + catalog + ) + + expect(adminStatus.phase).toBe('available') + expect(adminStatus.recommendedRelease?.version).toBe('1.5.0') + expect(dialogCatalog.mode).toBe('admin') + expect(dialogCatalog.channels?.stable).toHaveLength(2) + expect(dialogCatalog.channels?.preview).toHaveLength(2) + }) +}) diff --git a/tests/unit/update-installer.test.ts b/tests/unit/update-installer.test.ts new file mode 100644 index 0000000..014b8d1 --- /dev/null +++ b/tests/unit/update-installer.test.ts @@ -0,0 +1,33 @@ +import path from 'path' +import { describe, expect, it } from 'vitest' +import { UpdateInstaller } from '../../src/main/services/update/update-installer' + +describe('UpdateInstaller', () => { + it('builds downloaded package path under userData pending-update', () => { + const installer = new UpdateInstaller() + + const result = installer.getDownloadPath({ + version: '1.2.3', + channel: 'stable' + }) + + expect(result).toContain(path.join('logs', 'pending-update')) + expect(result).toContain('stable-1.2.3.exe') + }) + + it('returns null when downloaded package does not exist', async () => { + const installer = new UpdateInstaller() + + const result = await installer.getValidDownloadedRelease({ + version: '9.9.9', + channel: 'preview', + artifactKey: 'preview/9.9.9.exe', + sha256: 'missing', + size: 1, + publishedAt: '2026-03-21T10:00:00Z', + changelogKey: 'preview/9.9.9.md' + }) + + expect(result).toBeNull() + }) +}) diff --git a/tests/unit/update-service.test.ts b/tests/unit/update-service.test.ts new file mode 100644 index 0000000..af6ba28 --- /dev/null +++ b/tests/unit/update-service.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdateConfig } from '../../src/main/types/config.schema' +import type { UpdateCatalog, UpdateRelease, UpdateStatus } from '../../src/main/types/update.types' + +const mockPublishUpdateStatus = vi.fn() +const mockReadText = vi.fn() +const mockDownloadToFile = vi.fn() +const mockLoadCatalog = vi.fn() +const mockGetDialogCatalog = vi.fn() +const mockResolveUserStatus = vi.fn() +const mockResolveAdminStatus = vi.fn() +const mockGetDownloadPath = vi.fn() +const mockCalculateSha256 = vi.fn() +const mockInstallDownloadedRelease = vi.fn() +const mockGetValidDownloadedRelease = vi.fn() + +let currentUpdateConfig: UpdateConfig = { + enabled: true, + allowDevMode: true, + endpoint: 'http://localhost:9000', + accessKey: 'key', + secretKey: 'secret', + bucket: 'bucket', + region: 'us-east-1', + basePrefix: 'updates/win-portable', + checkIntervalMinutes: 30, + maxAdminHistoryPerChannel: 10 +} + +function createRelease(version: string, channel: 'stable' | 'preview' = 'stable'): UpdateRelease { + return { + version, + channel, + artifactKey: `${channel}/${version}.exe`, + sha256: `${channel}-${version}-sha`, + size: 1, + publishedAt: '2026-03-21T10:00:00Z', + changelogKey: `${channel}/${version}.md` + } +} + +vi.mock('../../src/main/services/config/config-manager', () => ({ + ConfigManager: { + getInstance: vi.fn(() => ({ + getConfig: () => ({ update: currentUpdateConfig }) + })) + } +})) + +vi.mock('../../src/main/services/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + })) +})) + +vi.mock('../../src/main/services/update/update-status-publisher', () => ({ + publishUpdateStatus: mockPublishUpdateStatus +})) + +vi.mock('../../src/main/services/update/update-storage-client', () => ({ + UpdateStorageClient: class { + readText = mockReadText + downloadToFile = mockDownloadToFile + } +})) + +vi.mock('../../src/main/services/update/update-catalog-service', () => ({ + UpdateCatalogService: class { + loadCatalog = mockLoadCatalog + getDialogCatalog = mockGetDialogCatalog + resolveUserStatus = mockResolveUserStatus + resolveAdminStatus = mockResolveAdminStatus + } +})) + +vi.mock('../../src/main/services/update/update-installer', () => ({ + UpdateInstaller: class { + getDownloadPath = mockGetDownloadPath + calculateSha256 = mockCalculateSha256 + installDownloadedRelease = mockInstallDownloadedRelease + getValidDownloadedRelease = mockGetValidDownloadedRelease + } +})) + +describe('UpdateService', () => { + beforeEach(() => { + vi.clearAllMocks() + currentUpdateConfig = { + enabled: true, + allowDevMode: true, + endpoint: 'http://localhost:9000', + accessKey: 'key', + secretKey: 'secret', + bucket: 'bucket', + region: 'us-east-1', + basePrefix: 'updates/win-portable', + checkIntervalMinutes: 30, + maxAdminHistoryPerChannel: 10 + } + mockReadText.mockReset() + mockDownloadToFile.mockReset() + mockLoadCatalog.mockReset() + mockGetDialogCatalog.mockReset() + mockResolveUserStatus.mockReset() + mockResolveAdminStatus.mockReset() + mockGetDownloadPath.mockReset() + mockCalculateSha256.mockReset() + mockInstallDownloadedRelease.mockReset() + mockGetValidDownloadedRelease.mockReset() + }) + + async function loadService() { + vi.resetModules() + const module = await import('../../src/main/services/update/update-service') + ;(module.UpdateService as unknown as { instance: unknown }).instance = null + return module.UpdateService.getInstance() + } + + it('initializes enabled update status from config', async () => { + const service = await loadService() + + service.initialize() + + expect(service.getStatus()).toMatchObject({ + enabled: true, + supported: true + }) + }) + + it('clears update state when user context becomes guest-like', async () => { + const service = await loadService() + + await service.setUserContext(null) + + expect(service.getStatus()).toMatchObject({ + currentUserType: null, + phase: 'idle', + recommendedRelease: undefined, + downloadedRelease: undefined + }) + expect(mockPublishUpdateStatus).toHaveBeenCalled() + }) + + it('checks updates for user and auto-downloads available recommendation', async () => { + const recommended = createRelease('1.1.0') + const catalog: UpdateCatalog = { + stable: [recommended], + preview: [] + } + const userStatus: Partial = { + phase: 'available', + recommendedRelease: recommended, + latestVersion: recommended.version, + latestChannel: recommended.channel, + message: `发现稳定版 ${recommended.version}` + } + + mockLoadCatalog.mockResolvedValue(catalog) + mockResolveUserStatus.mockResolvedValue(userStatus) + mockGetDownloadPath.mockReturnValue('D:/downloads/stable-1.1.0.exe') + mockCalculateSha256.mockResolvedValue(recommended.sha256) + + const service = await loadService() + await service.setUserContext('User') + + expect(mockLoadCatalog).toHaveBeenCalledWith('User') + expect(mockResolveUserStatus).toHaveBeenCalled() + expect(mockDownloadToFile).toHaveBeenCalledWith( + recommended.artifactKey, + 'D:/downloads/stable-1.1.0.exe' + ) + expect(service.getStatus()).toMatchObject({ + phase: 'downloaded', + latestVersion: '1.1.0', + latestChannel: 'stable' + }) + }) + + it('returns disabled catalog when update services are unavailable', async () => { + currentUpdateConfig = { + ...currentUpdateConfig, + enabled: false, + allowDevMode: false + } + const service = await loadService() + service.initialize() + + const result = service.getCatalog() + + expect(result).toEqual({ mode: 'disabled' }) + expect(mockGetDialogCatalog).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/update-status-publisher.test.ts b/tests/unit/update-status-publisher.test.ts new file mode 100644 index 0000000..357fefa --- /dev/null +++ b/tests/unit/update-status-publisher.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' + +const mockSend = vi.fn() +const mockGetAllWindows = vi.fn(() => [ + { webContents: { send: mockSend } }, + { webContents: { send: mockSend } } +]) + +vi.mock('electron', async () => { + const actual = await vi.importActual('electron') + return { + ...actual, + BrowserWindow: { + getAllWindows: mockGetAllWindows + } + } +}) + +describe('update-status-publisher', () => { + it('broadcasts status to all renderer windows', async () => { + const { publishUpdateStatus } = + await import('../../src/main/services/update/update-status-publisher') + + publishUpdateStatus({ + enabled: true, + supported: true, + phase: 'checking', + currentVersion: '1.0.0', + currentChannel: 'stable', + currentUserType: 'Admin' + }) + + expect(mockGetAllWindows).toHaveBeenCalledTimes(1) + expect(mockSend).toHaveBeenCalledTimes(2) + }) +})