refactor: organize tests and extract formatting utilities

- Move test files to lib/__tests__/ directory
- Extract formatting utilities (formatVersion, formatFileSize, formatDateTime)
  from fs-utils.ts to new utils.ts module
- Add Jest test configuration and test scripts
- Update component imports to use new utils module
- Add CLAUDE.md documentation for project structure

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 02:33:30 +08:00
parent 17a23611b6
commit 8227f36cbe
11 changed files with 5861 additions and 32 deletions

View File

@@ -0,0 +1,221 @@
import { validatePath, getProjects, getVersions, getApks } from '../fs-utils';
import * as fs from 'fs/promises';
// Mock fs module
jest.mock('fs/promises', () => ({
readdir: jest.fn(),
stat: jest.fn(),
}));
const mockedFs = fs as jest.Mocked<typeof fs>;
describe('validatePath', () => {
test('应该验证正常路径', () => {
expect(validatePath('/base', 'dir')).toBe('/base/dir');
expect(validatePath('/base', 'dir', 'subdir')).toBe('/base/dir/subdir');
});
test('应该解析相对路径', () => {
expect(validatePath('/base', './dir')).toBe('/base/dir');
expect(validatePath('/base/../base', 'dir')).toBe('/base/dir');
});
test('应该检测并拒绝路径遍历攻击', () => {
expect(() => validatePath('/base', '../etc')).toThrow('Invalid path: path traversal detected');
expect(() => validatePath('/base', 'dir', '../../etc')).toThrow('Invalid path: path traversal detected');
expect(() => validatePath('/base', '../base')).not.toThrow(); // 同级目录应被允许
});
test('应该处理特殊字符的路径', () => {
expect(validatePath('/base', 'dir with spaces')).toBe('/base/dir with spaces');
expect(validatePath('/base', 'dir-with-dashes')).toBe('/base/dir-with-dashes');
});
});
describe('getProjects', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('应该返回项目列表', async () => {
const mockDirs = ['project1', 'project2', 'not-a-dir.txt'];
mockedFs.readdir.mockResolvedValue(mockDirs as any);
// Mock stat for each directory
mockedFs.stat
.mockResolvedValueOnce({ isDirectory: () => true } as any)
.mockResolvedValueOnce({ isDirectory: () => true } as any)
.mockResolvedValueOnce({ isDirectory: () => false } as any);
const result = await getProjects();
expect(mockedFs.readdir).toHaveBeenCalledWith('./resources');
expect(result).toEqual(['project1', 'project2']);
});
test('应该处理读取目录错误', async () => {
mockedFs.readdir.mockRejectedValue(new Error('Permission denied'));
await expect(getProjects()).rejects.toThrow();
});
test('应该处理单个文件的状态检查错误', async () => {
mockedFs.readdir.mockResolvedValue(['project1', 'project2']);
mockedFs.stat
.mockResolvedValueOnce({ isDirectory: () => true } as any)
.mockRejectedValueOnce(new Error('ENOENT'));
const result = await getProjects();
expect(result).toEqual(['project1']);
});
});
describe('getVersions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('应该返回指定项目的版本列表并排序', async () => {
const mockVersions = ['1_0', '0_42', '2_1'];
mockedFs.readdir.mockResolvedValue(mockVersions as any);
mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
const result = await getVersions('test-project');
expect(mockedFs.readdir).toHaveBeenCalledWith(expect.stringContaining('test-project'));
expect(result).toEqual(['0_42', '1_0', '2_1']);
});
test('应该过滤掉非目录文件', async () => {
const mockVersions = ['1_0', 'readme.txt', '2_1'];
mockedFs.readdir.mockResolvedValue(mockVersions as any);
mockedFs.stat
.mockResolvedValueOnce({ isDirectory: () => true } as any)
.mockResolvedValueOnce({ isDirectory: () => false } as any)
.mockResolvedValueOnce({ isDirectory: () => true } as any);
const result = await getVersions('test-project');
expect(result).toEqual(['1_0', '2_1']);
});
});
describe('getApks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('应该返回指定版本的 APK 列表并按修改时间排序', async () => {
const mockFiles = ['app-v1.apk', 'app-v2.apk', 'readme.txt'];
mockedFs.readdir.mockResolvedValue(mockFiles as any);
const baseTime = new Date('2024-01-01T00:00:00');
mockedFs.stat
.mockResolvedValueOnce({
isDirectory: () => false,
size: 1024 * 1024,
mtime: baseTime,
} as any)
.mockResolvedValueOnce({
isDirectory: () => false,
size: 2 * 1024 * 1024,
mtime: new Date('2024-01-02T00:00:00'),
} as any)
.mockResolvedValueOnce({
isDirectory: () => false,
size: 100,
mtime: new Date('2024-01-03T00:00:00'),
} as any);
const result = await getApks('test-project', '1_0');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('app-v2.apk');
expect(result[1].name).toBe('app-v1.apk');
});
test('应该从文件名中提取 commit id', async () => {
const mockFiles = ['app-abc1234567.apk', 'app.apk'];
mockedFs.readdir.mockResolvedValue(mockFiles as any);
mockedFs.stat.mockResolvedValue({
isDirectory: () => false,
size: 1024 * 1024,
mtime: new Date('2024-01-01T00:00:00'),
} as any);
const result = await getApks('test-project', '1_0');
expect(result).toHaveLength(2);
expect(result[0].commit).toBe('abc1234567');
expect(result[1].commit).toBeNull();
});
test('应该按 commitId 过滤 APK', async () => {
const mockFiles = ['app-abc1234567.apk', 'app-def4567890.apk'];
mockedFs.readdir.mockResolvedValue(mockFiles as any);
const time1 = new Date('2024-01-01T00:00:00');
const time2 = new Date('2024-01-02T00:00:00');
mockedFs.stat
.mockResolvedValueOnce({
isDirectory: () => false,
size: 1024 * 1024,
mtime: time1,
} as any)
.mockResolvedValueOnce({
isDirectory: () => false,
size: 2 * 1024 * 1024,
mtime: time2,
} as any);
const result = await getApks('test-project', '1_0', 'abc123');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('app-abc1234567.apk');
});
test('应该忽略 commitId 的大小写', async () => {
const mockFiles = ['app-ABC1234567.apk'];
mockedFs.readdir.mockResolvedValue(mockFiles as any);
mockedFs.stat.mockResolvedValue({
isDirectory: () => false,
size: 1024 * 1024,
mtime: new Date('2024-01-01T00:00:00'),
} as any);
const result = await getApks('test-project', '1_0', 'abc');
expect(result).toHaveLength(1);
expect(result[0].commit).toBe('ABC1234567');
});
test('当 apks 目录不存在时应该返回空数组', async () => {
mockedFs.readdir.mockRejectedValue(new Error('ENOENT'));
const result = await getApks('test-project', '1_0');
expect(result).toEqual([]);
});
test('应该只返回 .apk 文件', async () => {
const mockFiles = ['app.apk', 'readme.txt', 'config.json', 'another.apk'];
mockedFs.readdir.mockResolvedValue(mockFiles as any);
mockedFs.stat.mockResolvedValue({
isDirectory: () => false,
size: 1024 * 1024,
mtime: new Date('2024-01-01T00:00:00'),
} as any);
const result = await getApks('test-project', '1_0');
expect(result).toHaveLength(2);
expect(result.every(apk => apk.name.endsWith('.apk'))).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
import { formatVersion, formatFileSize, formatDateTime } from '../utils';
describe('formatVersion', () => {
test('应该将下划线替换为点号', () => {
expect(formatVersion('0_42')).toBe('0.42');
expect(formatVersion('1_23_4')).toBe('1.23.4');
expect(formatVersion('2_0')).toBe('2.0');
});
test('应该处理没有下划线的版本号', () => {
expect(formatVersion('1.0')).toBe('1.0');
expect(formatVersion('2.3.4')).toBe('2.3.4');
});
test('应该处理空字符串', () => {
expect(formatVersion('')).toBe('');
});
});
describe('formatFileSize', () => {
test('应该格式化字节数', () => {
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(100)).toBe('100 B');
expect(formatFileSize(1023)).toBe('1023 B');
});
test('应该格式化KB', () => {
expect(formatFileSize(1024)).toBe('1.00 KB');
expect(formatFileSize(2048)).toBe('2.00 KB');
expect(formatFileSize(1536)).toBe('1.50 KB');
});
test('应该格式化MB', () => {
expect(formatFileSize(1024 * 1024)).toBe('1.00 MB');
expect(formatFileSize(2 * 1024 * 1024)).toBe('2.00 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.50 MB');
});
test('应该格式化GB', () => {
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1.00 GB');
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2.00 GB');
});
});
describe('formatDateTime', () => {
test('应该格式化日期时间为标准格式', () => {
const date = new Date('2024-01-15T09:30:00');
expect(formatDateTime(date)).toBe('2024-01-15 09:30');
});
test('应该正确处理午夜时间', () => {
const date = new Date('2024-12-31T00:00:00');
expect(formatDateTime(date)).toBe('2024-12-31 00:00');
});
test('应该正确处理月末日期', () => {
const date = new Date('2024-12-31T23:59:00');
expect(formatDateTime(date)).toBe('2024-12-31 23:59');
});
test('应该正确处理单数月份和日期', () => {
const date = new Date('2024-01-05T09:05:00');
expect(formatDateTime(date)).toBe('2024-01-05 09:05');
});
});

View File

@@ -101,31 +101,3 @@ export async function getApks(project: string, version: string, commitId?: strin
}
}
/**
* 格式化版本号显示 (0_42 -> 0.42)
*/
export function formatVersion(version: string): string {
return version.replace('_', '.');
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
/**
* 格式化日期时间
*/
export function formatDateTime(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}

31
lib/utils.ts Normal file
View File

@@ -0,0 +1,31 @@
// lib/utils.ts
// Client-safe utility functions
/**
* 格式化版本号显示 (0_42 -> 0.42)
*/
export function formatVersion(version: string): string {
return version.replace(/_/g, '.');
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
/**
* 格式化日期时间
*/
export function formatDateTime(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}