/** * @vitest-environment node * * 文件上传工具函数单元测试 * * 测试策略: * - 使用 vi.fn() 模拟 Playwright Page 对象和 Locator * - 测试 resolveFixturePath 函数的路径解析逻辑(核心测试重点) * - 验证错误处理和 E2ETestError 上下文完整性 * - 验证安全防护机制(路径遍历攻击防护) * - 注意:单元测试无法替代真实 E2E 集成测试(见 Story 3.3) * * Epic 2 经验教训: * - 单元测试覆盖率目标 80%,但无法发现真实 DOM 问题 * - 真实 E2E 测试是必需的,不是可选项 * - 本测试重点:路径解析逻辑、错误处理、安全防护 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Page } from '@playwright/test'; import * as fs from 'node:fs'; // 从主导出点导入,验证 index.ts 导出配置正确 import { uploadFileToField, E2ETestError, DEFAULT_TIMEOUTS, type FileUploadOptions } from '@d8d/e2e-test-utils'; // Mock fs 模块 vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, existsSync: vi.fn(), }; }); describe('uploadFileToField - 文件上传工具', () => { let mockPage: Page; let mockLocator: any; let mockExistsSync: ReturnType>; let consoleDebugSpy: ReturnType; beforeEach(() => { // 重置所有 mocks vi.clearAllMocks(); // Mock console.debug 减少测试输出噪音 consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); // 创建 mock locator mockLocator = { setInputFiles: vi.fn().mockResolvedValue(undefined), }; // 创建 mock page mockPage = { locator: vi.fn().mockReturnValue(mockLocator), waitForTimeout: vi.fn().mockResolvedValue(undefined), } as unknown as Page; // 获取 mock 的 existsSync mockExistsSync = vi.mocked(fs.existsSync); // 默认行为:文件存在 mockExistsSync.mockReturnValue(true); }); afterEach(() => { // 恢复 console.debug 原始实现 consoleDebugSpy.mockRestore(); }); describe('Task 2: 成功上传场景测试', () => { describe('Subtask 2.1: 默认 fixtures 目录上传', () => { it('应该成功上传文件(使用默认 fixtures 目录)', async () => { // Arrange const fileName = 'test-sample.jpg'; const selector = 'photo-upload'; // Act await uploadFileToField(mockPage, selector, fileName); // Assert expect(mockPage.locator).toHaveBeenCalledWith(selector); expect(mockLocator.setInputFiles).toHaveBeenCalledWith( expect.stringContaining(fileName), { timeout: DEFAULT_TIMEOUTS.static } ); // 默认 waitForUpload: true,应该调用 waitForTimeout expect(mockPage.waitForTimeout).toHaveBeenCalledWith(200); }); it('应该使用默认 fixtures 目录 "web/tests/fixtures"', async () => { // Arrange const fileName = 'sample.jpg'; const selector = 'input-file'; // Act await uploadFileToField(mockPage, selector, fileName); // Assert const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string; expect(filePathArg).toContain('web/tests/fixtures'); expect(filePathArg).toContain(fileName); }); }); describe('Subtask 2.2: 自定义 fixtures 目录上传', () => { it('应该使用自定义 fixtures 目录上传文件', async () => { // Arrange const customFixturesDir = 'custom/fixtures/path'; const fileName = 'test-sample.jpg'; const selector = 'file-upload'; // Act await uploadFileToField(mockPage, selector, fileName, { fixturesDir: customFixturesDir }); // Assert const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string; expect(filePathArg).toContain(customFixturesDir); expect(filePathArg).toContain(fileName); }); it('应该使用自定义 fixtures 目录解析绝对路径', async () => { // Arrange const customFixturesDir = 'tests/fixtures'; const fileName = 'documents/test-sample.pdf'; const selector = 'document-upload'; // Act await uploadFileToField(mockPage, selector, fileName, { fixturesDir: customFixturesDir }); // Assert const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string; expect(filePathArg).toContain(customFixturesDir); expect(filePathArg).toContain(fileName); }); }); describe('Subtask 2.3: 子目录文件上传', () => { it('应该支持子目录文件上传(如 images/sample.jpg)', async () => { // Arrange const fileName = 'images/sample-id-card.jpg'; const selector = 'photo-upload'; // Act await uploadFileToField(mockPage, selector, fileName); // Assert const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string; expect(filePathArg).toContain('images'); expect(filePathArg).toContain('sample-id-card.jpg'); }); it('应该支持多级子目录文件上传', async () => { // Arrange const fileName = 'documents/2024/01/test-sample.pdf'; const selector = 'document-upload'; // Act await uploadFileToField(mockPage, selector, fileName); // Assert const filePathArg = mockLocator.setInputFiles.mock.calls[0][0] as string; expect(filePathArg).toContain('documents/2024/01'); expect(filePathArg).toContain('test-sample.pdf'); }); }); describe('Subtask 2.4: 验证 setInputFiles API 调用', () => { it('应该使用正确的参数调用 setInputFiles', async () => { // Arrange const fileName = 'test.jpg'; const selector = 'file-input'; // Act await uploadFileToField(mockPage, selector, fileName); // Assert expect(mockLocator.setInputFiles).toHaveBeenCalledTimes(1); const callArgs = mockLocator.setInputFiles.mock.calls[0]; expect(callArgs[0]).toEqual(expect.any(String)); // 文件路径 expect(callArgs[1]).toEqual({ timeout: DEFAULT_TIMEOUTS.static }); }); it('应该先调用 locator 再调用 setInputFiles', async () => { // Arrange const fileName = 'test.jpg'; const selector = 'my-input'; // Act await uploadFileToField(mockPage, selector, fileName); // Assert - 验证调用顺序 expect(mockPage.locator).toHaveBeenCalledWith(selector); expect(mockLocator.setInputFiles).toHaveBeenCalled(); }); }); }); describe('Task 3: 错误场景测试', () => { describe('Subtask 3.1: 文件不存在错误', () => { it('应该在文件不存在时抛出 E2ETestError', async () => { // Arrange const nonExistentFile = 'non-existent-file.jpg'; mockExistsSync.mockReturnValue(false); // 文件不存在 // Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', nonExistentFile) ).rejects.toThrow(E2ETestError); }); it('文件不存在错误应该包含正确的上下文信息', async () => { // Arrange const nonExistentFile = 'missing-file.jpg'; mockExistsSync.mockReturnValue(false); // Act & Assert try { await uploadFileToField(mockPage, 'photo-upload', nonExistentFile); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.operation).toBe('uploadFileToField'); expect(e2eError.context.target).toContain(nonExistentFile); expect(e2eError.message).toContain('💡'); } }); }); describe('Subtask 3.2: 选择器无效错误', () => { it('应该在选择器无效时抛出 E2ETestError', async () => { // Arrange mockLocator.setInputFiles.mockRejectedValue( new Error('Element not found') ); const invalidSelector = 'invalid-file-input'; // Act & Assert await expect( uploadFileToField(mockPage, invalidSelector, 'test.jpg') ).rejects.toThrow(E2ETestError); }); it('选择器错误应该包含选择器上下文', async () => { // Arrange const invalidSelector = 'missing-input'; mockLocator.setInputFiles.mockRejectedValue( new Error('Timeout waiting for element') ); // Act & Assert try { await uploadFileToField(mockPage, invalidSelector, 'test.jpg'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.operation).toBe('uploadFileToField'); expect(e2eError.context.target).toContain(invalidSelector); } }); }); describe('Subtask 3.3: 验证错误消息包含正确的上下文信息', () => { it('文件不存在错误应该包含建议', async () => { // Arrange mockExistsSync.mockReturnValue(false); // Act & Assert try { await uploadFileToField(mockPage, 'input', 'missing.jpg'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.suggestion).toBeDefined(); expect(e2eError.context.suggestion).toContain('fixtures'); } }); it('选择器错误应该包含详细建议', async () => { // Arrange mockLocator.setInputFiles.mockRejectedValue( new Error('Element not found') ); // Act & Assert try { await uploadFileToField(mockPage, 'bad-selector', 'test.jpg'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.suggestion).toBeDefined(); expect(e2eError.context.suggestion).toContain('data-testid'); } }); }); }); describe('Task 4: 边界条件和安全测试', () => { describe('Subtask 4.1: 路径遍历攻击防护(../路径被拒绝)', () => { it('应该拒绝包含 ".." 的路径(路径遍历攻击防护)', async () => { // Arrange & Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', '../../../etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该拒绝包含 ".." 的子目录路径', async () => { // Arrange & Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', 'images/../../etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该拒绝包含 ".." 的相对路径', async () => { // Arrange & Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', '../test.jpg') ).rejects.toThrow(E2ETestError); }); }); describe('Subtask 4.2: 绝对路径被拒绝', () => { it('应该拒绝绝对路径(Linux)', async () => { // Arrange & Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', '/etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该拒绝绝对路径(Windows)', { skip: process.platform !== 'win32' }, async () => { // 注意:此测试仅在 Windows 平台上运行 // 在 Linux 上,Node.js 的 path.isAbsolute() 不识别 Windows 路径格式 // Arrange & Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', 'C:\\Windows\\System32\\config') ).rejects.toThrow(E2ETestError); }); it('路径遍历错误应该包含安全建议', async () => { // Arrange & Act & Assert try { await uploadFileToField(mockPage, 'photo-upload', '/etc/passwd'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.suggestion).toBeDefined(); expect(e2eError.context.suggestion).toContain('fixtures'); } }); }); describe('Subtask 4.3: 路径遍历验证(解析后的路径在 fixtures 目录内)', () => { it('应该验证解析后的路径在 fixtures 目录内(防止路径遍历)', async () => { // 此测试验证 resolveFixturePath 的安全检查 // 路径如 "images/../../../etc/passwd" 会被拒绝 // 即使经过 path.normalize 处理后 // Arrange & Act & Assert await expect( uploadFileToField(mockPage, 'photo-upload', 'images/../../../etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该拒绝路径遍历绕过尝试', async () => { // 尝试使用 ./ 绕过 await expect( uploadFileToField(mockPage, 'photo-upload', './../../../etc/passwd') ).rejects.toThrow(E2ETestError); }); }); describe('Subtask 4.4: 超时配置生效', () => { it('应该使用自定义超时配置', async () => { // Arrange const customTimeout = 10000; // Act await uploadFileToField(mockPage, 'photo-upload', 'test.jpg', { timeout: customTimeout }); // Assert expect(mockLocator.setInputFiles).toHaveBeenCalledWith( expect.anything(), { timeout: customTimeout } ); }); it('应该使用默认超时配置(DEFAULT_TIMEOUTS.static)', async () => { // Arrange & Act await uploadFileToField(mockPage, 'photo-upload', 'test.jpg'); // Assert expect(mockLocator.setInputFiles).toHaveBeenCalledWith( expect.anything(), { timeout: DEFAULT_TIMEOUTS.static } ); }); }); }); describe('Task 4+: 边界条件和额外安全测试', () => { describe('Subtask 4.5: 边界条件测试', () => { it('应该拒绝空文件名(文件不存在)', async () => { // 空文件名会导致无效路径,文件不存在检查应该失败 mockExistsSync.mockReturnValue(false); await expect( uploadFileToField(mockPage, 'photo-upload', '') ).rejects.toThrow(E2ETestError); }); it('应该拒绝只包含空白的文件名', async () => { // 只包含空白的文件名会被 path.normalize 处理,但文件不存在 mockExistsSync.mockReturnValue(false); await expect( uploadFileToField(mockPage, 'photo-upload', ' ') ).rejects.toThrow(E2ETestError); }); it('应该处理带特殊字符的文件名(如果文件存在则接受)', async () => { // 函数不验证文件名模式,只检查文件是否存在 // 如果文件存在,则接受该文件名 const fileNameWithSpecialChars = 'test@#$file.jpg'; mockExistsSync.mockReturnValue(true); await uploadFileToField(mockPage, 'photo-upload', fileNameWithSpecialChars); expect(mockLocator.setInputFiles).toHaveBeenCalled(); }); it('应该处理超长文件名(如果文件存在则接受)', async () => { // 函数不验证文件名长度,只检查文件是否存在 // 实际的文件系统限制会在创建文件时生效 const longFileName = 'a'.repeat(200) + '.jpg'; mockExistsSync.mockReturnValue(true); await uploadFileToField(mockPage, 'photo-upload', longFileName); expect(mockLocator.setInputFiles).toHaveBeenCalled(); }); it('应该拒绝超长路径(文件不存在)', async () => { // 超长路径可能导致文件不存在 const veryLongFileName = 'a'.repeat(300) + '.jpg'; mockExistsSync.mockReturnValue(false); await expect( uploadFileToField(mockPage, 'photo-upload', veryLongFileName) ).rejects.toThrow(E2ETestError); }); }); describe('Subtask 4.6: 路径遍历安全验证测试', () => { it('应该拒绝路径遍历:sub/../../../etc/passwd', async () => { // path.normalize("sub/../../../etc/passwd") = "../../etc/passwd" // 规范化后以 ".." 开头,应该被拒绝 await expect( uploadFileToField(mockPage, 'photo-upload', 'sub/../../../etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该拒绝路径遍历:a/b/../../../../../etc/passwd', async () => { // path.normalize 规范化为 "../../../etc/passwd" await expect( uploadFileToField(mockPage, 'photo-upload', 'a/b/../../../../../etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该拒绝路径遍历:./../../../etc/passwd', async () => { // path.normalize 规范化为 "../../../etc/passwd" await expect( uploadFileToField(mockPage, 'photo-upload', './../../../etc/passwd') ).rejects.toThrow(E2ETestError); }); it('应该接受有效的子目录路径', async () => { // 验证正常子目录路径仍然有效 // 这个测试确保我们没有过度限制合法的文件路径 mockExistsSync.mockReturnValue(true); await uploadFileToField(mockPage, 'photo-upload', 'images/sample.jpg'); expect(mockLocator.setInputFiles).toHaveBeenCalled(); }); }); }); describe('其他配置选项测试', () => { it('应该支持 waitForUpload: false(不等待上传完成)', async () => { // Arrange const fileName = 'test.jpg'; const selector = 'file-upload'; // Act await uploadFileToField(mockPage, selector, fileName, { waitForUpload: false }); // Assert expect(mockPage.waitForTimeout).not.toHaveBeenCalled(); expect(mockLocator.setInputFiles).toHaveBeenCalled(); }); it('应该同时支持多个自定义选项', async () => { // Arrange const customTimeout = 8000; const customFixturesDir = 'custom/fixtures'; // Act await uploadFileToField(mockPage, 'file-input', 'test.pdf', { timeout: customTimeout, fixturesDir: customFixturesDir, waitForUpload: false }); // Assert expect(mockLocator.setInputFiles).toHaveBeenCalledWith( expect.stringContaining(customFixturesDir), { timeout: customTimeout } ); expect(mockPage.waitForTimeout).not.toHaveBeenCalled(); }); }); describe('主导出验证 (index.ts)', () => { it('应该正确导出 uploadFileToField 函数', () => { expect(uploadFileToField).toBeDefined(); expect(typeof uploadFileToField).toBe('function'); }); it('应该正确导出 FileUploadOptions 类型', () => { const options: FileUploadOptions = { timeout: 10000, fixturesDir: 'custom/fixtures', waitForUpload: false }; expect(options).toBeDefined(); }); }); });