cherry-studio/src/main/services/__tests__/LocalTransferService.test.ts
槑囿脑袋 fc3e92e2f7
refactor: change qrcode landrop to lantransfer (#11968)
* refactor: change qrcode landrop to lantransfer

* chore: update docs and tests

* fix: pr review

* fix: pr review

* chore: remove qrcode dependency

* fix: pr review

* fix: format

* fix: test
2025-12-21 17:39:23 +08:00

482 lines
14 KiB
TypeScript

import { EventEmitter } from 'events'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
// Create mock objects before vi.mock calls
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
let mockMainWindow: {
isDestroyed: Mock
webContents: { send: Mock }
} | null = null
let mockBrowser: EventEmitter & {
start: Mock
stop: Mock
removeAllListeners: Mock
}
let mockBonjour: {
find: Mock
destroy: Mock
}
// Mock dependencies before importing the service
vi.mock('@logger', () => ({
loggerService: {
withContext: () => mockLogger
}
}))
vi.mock('../WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => mockMainWindow)
}
}))
vi.mock('bonjour-service', () => ({
default: vi.fn(() => mockBonjour)
}))
describe('LocalTransferService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
// Reset mock objects
mockMainWindow = {
isDestroyed: vi.fn(() => false),
webContents: { send: vi.fn() }
}
mockBrowser = Object.assign(new EventEmitter(), {
start: vi.fn(),
stop: vi.fn(),
removeAllListeners: vi.fn()
})
mockBonjour = {
find: vi.fn(() => mockBrowser),
destroy: vi.fn()
}
})
afterEach(() => {
vi.resetAllMocks()
})
describe('startDiscovery', () => {
it('should set isScanning to true and start browser', async () => {
const { localTransferService } = await import('../LocalTransferService')
const state = localTransferService.startDiscovery()
expect(state.isScanning).toBe(true)
expect(state.lastScanStartedAt).toBeDefined()
expect(mockBonjour.find).toHaveBeenCalledWith({ type: 'cherrystudio', protocol: 'tcp' })
expect(mockBrowser.start).toHaveBeenCalled()
})
it('should clear services when resetList is true', async () => {
const { localTransferService } = await import('../LocalTransferService')
// First, start discovery and add a service
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local'
})
expect(localTransferService.getState().services).toHaveLength(1)
// Now restart with resetList
const state = localTransferService.startDiscovery({ resetList: true })
expect(state.services).toHaveLength(0)
})
it('should broadcast state after starting discovery', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
expect(mockMainWindow?.webContents.send).toHaveBeenCalled()
})
it('should handle browser.start() error', async () => {
mockBrowser.start.mockImplementation(() => {
throw new Error('Failed to start mDNS')
})
const { localTransferService } = await import('../LocalTransferService')
const state = localTransferService.startDiscovery()
expect(state.lastError).toBe('Failed to start mDNS')
expect(mockLogger.error).toHaveBeenCalled()
})
})
describe('stopDiscovery', () => {
it('should set isScanning to false and stop browser', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
const state = localTransferService.stopDiscovery()
expect(state.isScanning).toBe(false)
expect(mockBrowser.stop).toHaveBeenCalled()
})
it('should handle browser.stop() error gracefully', async () => {
mockBrowser.stop.mockImplementation(() => {
throw new Error('Stop failed')
})
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
// Should not throw
expect(() => localTransferService.stopDiscovery()).not.toThrow()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should broadcast state after stopping', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
vi.clearAllMocks()
localTransferService.stopDiscovery()
expect(mockMainWindow?.webContents.send).toHaveBeenCalled()
})
})
describe('browser events', () => {
it('should add service on "up" event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local',
type: 'cherrystudio',
protocol: 'tcp'
})
const state = localTransferService.getState()
expect(state.services).toHaveLength(1)
expect(state.services[0].name).toBe('Test Service')
expect(state.services[0].port).toBe(12345)
expect(state.services[0].addresses).toContain('192.168.1.100')
})
it('should remove service on "down" event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
// Add service
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local'
})
expect(localTransferService.getState().services).toHaveLength(1)
// Remove service
mockBrowser.emit('down', {
name: 'Test Service',
host: 'localhost',
port: 12345,
fqdn: 'test.local'
})
expect(localTransferService.getState().services).toHaveLength(0)
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('removed'))
})
it('should set lastError on "error" event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('error', new Error('Discovery failed'))
const state = localTransferService.getState()
expect(state.lastError).toBe('Discovery failed')
expect(mockLogger.error).toHaveBeenCalled()
})
it('should handle non-Error objects in error event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('error', 'String error message')
const state = localTransferService.getState()
expect(state.lastError).toBe('String error message')
})
})
describe('getState', () => {
it('should return sorted services by name', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Zebra Service',
host: 'host1',
port: 1001,
addresses: ['192.168.1.1']
})
mockBrowser.emit('up', {
name: 'Alpha Service',
host: 'host2',
port: 1002,
addresses: ['192.168.1.2']
})
const state = localTransferService.getState()
expect(state.services[0].name).toBe('Alpha Service')
expect(state.services[1].name).toBe('Zebra Service')
})
it('should include all state properties', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
const state = localTransferService.getState()
expect(state).toHaveProperty('services')
expect(state).toHaveProperty('isScanning')
expect(state).toHaveProperty('lastScanStartedAt')
expect(state).toHaveProperty('lastUpdatedAt')
})
})
describe('getPeerById', () => {
it('should return peer when exists', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local'
})
const services = localTransferService.getState().services
const peer = localTransferService.getPeerById(services[0].id)
expect(peer).toBeDefined()
expect(peer?.name).toBe('Test Service')
})
it('should return undefined when peer does not exist', async () => {
const { localTransferService } = await import('../LocalTransferService')
const peer = localTransferService.getPeerById('non-existent-id')
expect(peer).toBeUndefined()
})
})
describe('normalizeService', () => {
it('should deduplicate addresses', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100', '192.168.1.100', '10.0.0.1'],
referer: { address: '192.168.1.100' }
})
const services = localTransferService.getState().services
expect(services[0].addresses).toHaveLength(2)
expect(services[0].addresses).toContain('192.168.1.100')
expect(services[0].addresses).toContain('10.0.0.1')
})
it('should filter empty addresses', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100', '', null as any]
})
const services = localTransferService.getState().services
expect(services[0].addresses).toEqual(['192.168.1.100'])
})
it('should convert txt null/undefined values to empty strings', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
txt: {
version: '1.0',
nullValue: null,
undefinedValue: undefined,
numberValue: 42
}
})
const services = localTransferService.getState().services
expect(services[0].txt).toEqual({
version: '1.0',
nullValue: '',
undefinedValue: '',
numberValue: '42'
})
})
it('should not include txt when empty', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
txt: {}
})
const services = localTransferService.getState().services
expect(services[0].txt).toBeUndefined()
})
})
describe('dispose', () => {
it('should clean up all resources', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100']
})
localTransferService.dispose()
expect(localTransferService.getState().services).toHaveLength(0)
expect(localTransferService.getState().isScanning).toBe(false)
expect(mockBrowser.removeAllListeners).toHaveBeenCalled()
expect(mockBonjour.destroy).toHaveBeenCalled()
})
it('should handle bonjour.destroy() error gracefully', async () => {
mockBonjour.destroy.mockImplementation(() => {
throw new Error('Destroy failed')
})
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
// Should not throw
expect(() => localTransferService.dispose()).not.toThrow()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should be safe to call multiple times', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
expect(() => {
localTransferService.dispose()
localTransferService.dispose()
}).not.toThrow()
})
})
describe('broadcastState', () => {
it('should not throw when main window is null', async () => {
mockMainWindow = null
const { localTransferService } = await import('../LocalTransferService')
// Should not throw
expect(() => localTransferService.startDiscovery()).not.toThrow()
})
it('should not throw when main window is destroyed', async () => {
mockMainWindow = {
isDestroyed: vi.fn(() => true),
webContents: { send: vi.fn() }
}
const { localTransferService } = await import('../LocalTransferService')
// Should not throw
expect(() => localTransferService.startDiscovery()).not.toThrow()
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled()
})
})
describe('restartBrowser', () => {
it('should destroy old bonjour instance to prevent socket leaks', async () => {
const { localTransferService } = await import('../LocalTransferService')
// First start
localTransferService.startDiscovery()
expect(mockBonjour.destroy).not.toHaveBeenCalled()
// Restart - should destroy old instance
localTransferService.startDiscovery()
expect(mockBonjour.destroy).toHaveBeenCalled()
})
it('should remove all listeners from old browser', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
localTransferService.startDiscovery()
expect(mockBrowser.removeAllListeners).toHaveBeenCalled()
})
})
})