mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
test: enhance download and fetch utility test coverage with bug fix (#7891)
* test: enhance download and fetch utility test coverage - Add MIME type handling tests for data URLs in download.test.ts - Add timestamp generation tests for blob and network downloads - Add Content-Type header handling test for extensionless files - Add format parameter tests (markdown/html/text) for fetchWebContent - Add timeout signal handling tests for fetch operations - Add combined signal (user + timeout) test for AbortSignal.any These tests improve coverage of edge cases and ensure critical functionality is properly tested. * fix: add missing error handling for fetch in download utility - Add .catch() handler for network request failures in download() - Use window.message.error() for user-friendly error notifications - Update tests to verify error handling behavior - Ensure proper error messages are shown to users This fixes a missing error handler that was discovered during test development. * refactor: improve test structure and add i18n support for download utility - Unified test structure with two-layer describe blocks (filename -> function name) - Added afterEach with restoreAllMocks for consistent mock cleanup - Removed individual mockRestore calls in favor of centralized cleanup - Added i18n support to download.ts for error messages - Updated error handling logic to avoid duplicate messages - Updated test expectations to match new i18n error messages * test: fix react-i18next mock for Markdown test Add missing initReactI18next to mock to resolve test failures caused by i18n initialization when download utility imports i18n module.
This commit is contained in:
parent
186f0ed06f
commit
97dbfe492e
@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
|
||||
241
src/renderer/src/utils/__tests__/download.test.ts
Normal file
241
src/renderer/src/utils/__tests__/download.test.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock @renderer/i18n to avoid initialization issues
|
||||
vi.mock('@renderer/i18n', () => ({
|
||||
default: {
|
||||
t: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'message.download.failed': '下载失败',
|
||||
'message.download.failed.network': '下载失败,请检查网络'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { download } from '../download'
|
||||
|
||||
// Mock DOM 方法
|
||||
const mockCreateElement = vi.fn()
|
||||
const mockAppendChild = vi.fn()
|
||||
const mockClick = vi.fn()
|
||||
|
||||
// Mock URL API
|
||||
const mockCreateObjectURL = vi.fn()
|
||||
const mockRevokeObjectURL = vi.fn()
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
// Mock window.message
|
||||
const mockMessage = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn()
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const createMockResponse = (options = {}) => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
blob: () => Promise.resolve(new Blob(['test'])),
|
||||
...options
|
||||
})
|
||||
|
||||
describe('download', () => {
|
||||
describe('download', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// 设置 window.message mock
|
||||
Object.defineProperty(window, 'message', { value: mockMessage, writable: true })
|
||||
|
||||
// 设置 DOM mock
|
||||
const mockElement = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: mockClick,
|
||||
remove: vi.fn()
|
||||
}
|
||||
mockCreateElement.mockReturnValue(mockElement)
|
||||
|
||||
Object.defineProperty(document, 'createElement', { value: mockCreateElement })
|
||||
Object.defineProperty(document.body, 'appendChild', { value: mockAppendChild })
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL })
|
||||
|
||||
global.fetch = mockFetch
|
||||
mockCreateObjectURL.mockReturnValue('blob:mock-url')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Direct download support', () => {
|
||||
it('should handle local file URLs', () => {
|
||||
download('file:///path/to/document.pdf', 'test.pdf')
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.href).toBe('file:///path/to/document.pdf')
|
||||
expect(element.download).toBe('test.pdf')
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle blob URLs', () => {
|
||||
download('blob:http://localhost:3000/12345')
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.href).toBe('blob:http://localhost:3000/12345')
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle data URLs', () => {
|
||||
const dataUrl =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
|
||||
|
||||
download(dataUrl)
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.href).toBe(dataUrl)
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle different MIME types in data URLs', async () => {
|
||||
const now = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
|
||||
// 只有 image/png 和 image/jpeg 会直接下载
|
||||
const directDownloadTests = [
|
||||
{ url: 'data:image/jpeg;base64,xxx', expectedExt: '.jpg' },
|
||||
{ url: 'data:image/png;base64,xxx', expectedExt: '.png' }
|
||||
]
|
||||
|
||||
directDownloadTests.forEach(({ url, expectedExt }) => {
|
||||
mockCreateElement.mockClear()
|
||||
download(url)
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.download).toBe(`${now}_download${expectedExt}`)
|
||||
})
|
||||
|
||||
// 其他类型会通过 fetch 处理
|
||||
mockCreateElement.mockClear()
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
headers: new Headers({ 'Content-Type': 'application/pdf' })
|
||||
})
|
||||
)
|
||||
|
||||
download('data:application/pdf;base64,xxx')
|
||||
await waitForAsync()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should generate filename with timestamp for blob URLs', () => {
|
||||
const now = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
|
||||
download('blob:http://localhost:3000/12345')
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.download).toBe(`${now}_diagram.svg`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filename handling', () => {
|
||||
it('should extract filename from file path', () => {
|
||||
download('file:///Users/test/Documents/report.pdf')
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.download).toBe('report.pdf')
|
||||
})
|
||||
|
||||
it('should handle URL encoded filenames', () => {
|
||||
download('file:///path/to/%E6%96%87%E6%A1%A3.pdf') // 编码的"文档.pdf"
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.download).toBe('文档.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Network download', () => {
|
||||
it('should handle successful network request', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse())
|
||||
|
||||
download('https://example.com/file.pdf', 'custom.pdf')
|
||||
await waitForAsync()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/file.pdf')
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should extract filename from URL and headers', async () => {
|
||||
const headers = new Headers()
|
||||
headers.set('Content-Disposition', 'attachment; filename="server-file.pdf"')
|
||||
mockFetch.mockResolvedValue(createMockResponse({ headers }))
|
||||
|
||||
download('https://example.com/files/document.docx')
|
||||
await waitForAsync()
|
||||
|
||||
// 验证下载被触发(具体文件名由实现决定)
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should add timestamp to network downloaded files', async () => {
|
||||
const now = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
|
||||
mockFetch.mockResolvedValue(createMockResponse())
|
||||
|
||||
download('https://example.com/file.pdf')
|
||||
await waitForAsync()
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.download).toBe(`${now}_file.pdf`)
|
||||
})
|
||||
|
||||
it('should handle Content-Type when filename has no extension', async () => {
|
||||
const headers = new Headers()
|
||||
headers.set('Content-Type', 'application/pdf')
|
||||
mockFetch.mockResolvedValue(createMockResponse({ headers }))
|
||||
|
||||
download('https://example.com/download')
|
||||
await waitForAsync()
|
||||
|
||||
const element = mockCreateElement.mock.results[0].value
|
||||
expect(element.download).toMatch(/\d+_download\.pdf$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const networkError = new Error('Network error')
|
||||
mockFetch.mockRejectedValue(networkError)
|
||||
|
||||
expect(() => download('https://example.com/file.pdf')).not.toThrow()
|
||||
await waitForAsync()
|
||||
|
||||
expect(mockMessage.error).toHaveBeenCalledWith('下载失败:Network error')
|
||||
})
|
||||
|
||||
it('should handle fetch errors without message', async () => {
|
||||
mockFetch.mockRejectedValue(new Error())
|
||||
|
||||
expect(() => download('https://example.com/file.pdf')).not.toThrow()
|
||||
await waitForAsync()
|
||||
|
||||
expect(mockMessage.error).toHaveBeenCalledWith('下载失败,请检查网络')
|
||||
})
|
||||
|
||||
it('should handle HTTP errors gracefully', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404 })
|
||||
|
||||
expect(() => download('https://example.com/file.pdf')).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
208
src/renderer/src/utils/__tests__/fetch.test.ts
Normal file
208
src/renderer/src/utils/__tests__/fetch.test.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock 外部依赖
|
||||
vi.mock('turndown', () => ({
|
||||
default: vi.fn(() => ({
|
||||
turndown: vi.fn(() => '# Test content')
|
||||
}))
|
||||
}))
|
||||
vi.mock('@mozilla/readability', () => ({
|
||||
Readability: vi.fn(() => ({
|
||||
parse: vi.fn(() => ({
|
||||
title: 'Test Article',
|
||||
content: '<p>Test content</p>',
|
||||
textContent: 'Test content'
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
vi.mock('@reduxjs/toolkit', () => ({
|
||||
nanoid: vi.fn(() => 'test-id')
|
||||
}))
|
||||
|
||||
import { fetchRedirectUrl, fetchWebContent, fetchWebContents } from '../fetch'
|
||||
|
||||
// 设置基础 mocks
|
||||
global.DOMParser = vi.fn().mockImplementation(() => ({
|
||||
parseFromString: vi.fn(() => ({}))
|
||||
})) as any
|
||||
|
||||
global.window = {
|
||||
api: {
|
||||
searchService: {
|
||||
openUrlInSearchWindow: vi.fn()
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
// 辅助函数
|
||||
const createMockResponse = (overrides = {}) =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: vi.fn().mockResolvedValue('<html><body>Test content</body></html>'),
|
||||
...overrides
|
||||
}) as unknown as Response
|
||||
|
||||
describe('fetch', () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch 和 AbortSignal
|
||||
global.fetch = vi.fn()
|
||||
global.AbortSignal = {
|
||||
timeout: vi.fn(() => ({})),
|
||||
any: vi.fn(() => ({}))
|
||||
} as any
|
||||
|
||||
// 清理 mock 调用历史
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('fetchWebContent', () => {
|
||||
it('should fetch and return content successfully', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse())
|
||||
|
||||
const result = await fetchWebContent('https://example.com')
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Test Article',
|
||||
url: 'https://example.com',
|
||||
content: '# Test content'
|
||||
})
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should use browser mode when specified', async () => {
|
||||
vi.mocked(window.api.searchService.openUrlInSearchWindow).mockResolvedValueOnce(
|
||||
'<html><body>Browser content</body></html>'
|
||||
)
|
||||
|
||||
const result = await fetchWebContent('https://example.com', 'markdown', true)
|
||||
|
||||
expect(result.content).toBe('# Test content')
|
||||
expect(window.api.searchService.openUrlInSearchWindow).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// 无效 URL
|
||||
const invalidResult = await fetchWebContent('not-a-url')
|
||||
expect(invalidResult.content).toBe('No content found')
|
||||
|
||||
// 网络错误
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error'))
|
||||
const networkResult = await fetchWebContent('https://example.com')
|
||||
expect(networkResult.content).toBe('No content found')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should rethrow abort errors', async () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(abortError)
|
||||
|
||||
await expect(fetchWebContent('https://example.com')).rejects.toThrow(DOMException)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['markdown', '# Test content'],
|
||||
['html', '<p>Test content</p>'],
|
||||
['text', 'Test content']
|
||||
])('should return %s format correctly', async (format, expectedContent) => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse())
|
||||
|
||||
const result = await fetchWebContent('https://example.com', format as any)
|
||||
|
||||
expect(result.content).toBe(expectedContent)
|
||||
expect(result.title).toBe('Test Article')
|
||||
expect(result.url).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('should handle timeout signal in AbortSignal.any', async () => {
|
||||
const mockTimeoutSignal = new AbortController().signal
|
||||
vi.spyOn(global.AbortSignal, 'timeout').mockReturnValue(mockTimeoutSignal)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse())
|
||||
|
||||
await fetchWebContent('https://example.com')
|
||||
|
||||
// 验证 AbortSignal.timeout 是否被调用,并传入 30000ms
|
||||
expect(global.AbortSignal.timeout).toHaveBeenCalledWith(30000)
|
||||
|
||||
vi.spyOn(global.AbortSignal, 'timeout').mockRestore()
|
||||
})
|
||||
|
||||
it('should combine user signal with timeout signal', async () => {
|
||||
const userController = new AbortController()
|
||||
const mockAnyCalls: any[] = []
|
||||
|
||||
vi.spyOn(global.AbortSignal, 'any').mockImplementation((signals) => {
|
||||
mockAnyCalls.push(signals)
|
||||
return new AbortController().signal
|
||||
})
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse())
|
||||
|
||||
await fetchWebContent('https://example.com', 'markdown', false, {
|
||||
signal: userController.signal
|
||||
})
|
||||
|
||||
// 验证 AbortSignal.any 是否被调用,并传入两个信号
|
||||
expect(mockAnyCalls).toHaveLength(1)
|
||||
expect(mockAnyCalls[0]).toHaveLength(2)
|
||||
expect(mockAnyCalls[0]).toContain(userController.signal)
|
||||
|
||||
vi.spyOn(global.AbortSignal, 'any').mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchWebContents', () => {
|
||||
it('should fetch multiple URLs in parallel', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()).mockResolvedValueOnce(createMockResponse())
|
||||
|
||||
const urls = ['https://example1.com', 'https://example2.com']
|
||||
const results = await fetchWebContents(urls)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].content).toBe('# Test content')
|
||||
expect(results[1].content).toBe('# Test content')
|
||||
})
|
||||
|
||||
it('should handle partial failures gracefully', async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce(createMockResponse())
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const results = await fetchWebContents(['https://success.com', 'https://fail.com'])
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].content).toBe('# Test content')
|
||||
expect(results[1].content).toBe('No content found')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRedirectUrl', () => {
|
||||
it('should return final redirect URL', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
url: 'https://redirected.com/final'
|
||||
} as any)
|
||||
|
||||
const result = await fetchRedirectUrl('https://example.com')
|
||||
|
||||
expect(result).toBe('https://redirected.com/final')
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should return original URL on error', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const result = await fetchRedirectUrl('https://example.com')
|
||||
expect(result).toBe('https://example.com')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,3 +1,5 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
export const download = (url: string, filename?: string) => {
|
||||
// 处理可直接通过 <a> 标签下载的 URL:
|
||||
// - 本地文件 ( file:// )
|
||||
@ -76,6 +78,15 @@ export const download = (url: string, filename?: string) => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
link.remove()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Download failed:', error)
|
||||
// 显示用户友好的错误提示
|
||||
if (error.message) {
|
||||
window.message?.error(`${i18n.t('message.download.failed')}:${error.message}`)
|
||||
} else {
|
||||
window.message?.error(i18n.t('message.download.failed.network'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数:根据MIME类型获取文件扩展名
|
||||
|
||||
Loading…
Reference in New Issue
Block a user