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:
Jason Young 2025-07-10 14:35:40 +08:00 committed by GitHub
parent 186f0ed06f
commit 97dbfe492e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 465 additions and 1 deletions

View File

@ -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

View 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()
})
})
})
})

View 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()
})
})
})

View File

@ -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类型获取文件扩展名