| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- /**
- * @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<typeof vi.mocked<typeof fs.existsSync>>;
- let consoleDebugSpy: ReturnType<typeof vi.spyOn>;
- 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();
- });
- });
- });
|