Skip to content

Commit ee7daf5

Browse files
Add file utility functions with unit tests (#48)
- Introduced `fileUtils.ts` containing `readJsonFile` and `writeJsonFile` functions for reading and writing JSON files, respectively. - Implemented error handling using `FileUtilsError` for invalid paths and file operation failures. - Created `fileUtils.test.ts` to validate the functionality of the file utility functions, ensuring proper error handling and file operations. These additions enhance file management capabilities and improve test coverage for file-related operations.
1 parent 52f747e commit ee7daf5

File tree

11 files changed

+328
-98
lines changed

11 files changed

+328
-98
lines changed

workers/main/src/activities/fetchFinancialData.ts

Lines changed: 0 additions & 32 deletions
This file was deleted.

workers/main/src/activities/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './weeklyFinancialReports';
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
2+
3+
import { AppError } from '../../common/errors';
4+
import { writeJsonFile } from '../../common/fileUtils';
5+
import { RedminePool } from '../../common/RedminePool';
6+
import { getTargetUnits } from './getTargetUnits';
7+
8+
type TargetUnit = {
9+
group_id: number;
10+
group_name: string;
11+
project_id: number;
12+
project_name: string;
13+
user_id: number;
14+
username: string;
15+
spent_on: string;
16+
total_hours: number;
17+
};
18+
19+
interface TargetUnitRepositoryMock {
20+
mockClear: () => void;
21+
mockImplementation: (
22+
impl: () => { getTargetUnits: () => Promise<TargetUnit[]> },
23+
) => void;
24+
}
25+
26+
interface RedminePoolMock {
27+
mockClear: () => void;
28+
mockImplementation: (
29+
impl: () => { getPool: () => string; endPool: () => void },
30+
) => void;
31+
}
32+
33+
const defaultUnit: TargetUnit = {
34+
group_id: 1,
35+
group_name: 'Group',
36+
project_id: 2,
37+
project_name: 'Project',
38+
user_id: 3,
39+
username: 'User',
40+
spent_on: '2024-06-01',
41+
total_hours: 8,
42+
};
43+
44+
function createMockUnit(overrides: Partial<TargetUnit> = {}): TargetUnit {
45+
return { ...defaultUnit, ...overrides };
46+
}
47+
48+
async function setupTargetUnitRepositoryMock(): Promise<TargetUnitRepositoryMock> {
49+
const imported = await vi.importMock(
50+
'../../services/TargetUnit/TargetUnitRepository',
51+
);
52+
const repo = vi.mocked(
53+
imported.TargetUnitRepository,
54+
) as TargetUnitRepositoryMock;
55+
56+
repo.mockClear();
57+
58+
return repo;
59+
}
60+
61+
function setupRedminePoolMock(endPool: Mock) {
62+
(RedminePool as unknown as RedminePoolMock).mockClear();
63+
(RedminePool as unknown as RedminePoolMock).mockImplementation(() => ({
64+
getPool: vi.fn(() => 'mockPool'),
65+
endPool,
66+
}));
67+
}
68+
69+
vi.mock('../../common/RedminePool', () => ({
70+
RedminePool: vi.fn().mockImplementation(() => ({
71+
getPool: vi.fn(() => 'mockPool'),
72+
endPool: vi.fn(),
73+
})),
74+
}));
75+
vi.mock('../../services/TargetUnit/TargetUnitRepository', () => ({
76+
TargetUnitRepository: vi.fn(),
77+
}));
78+
vi.mock('../../common/fileUtils', () => ({
79+
writeJsonFile: vi.fn(),
80+
}));
81+
vi.mock('../../configs/redmineDatabase', () => ({
82+
redmineDatabaseConfig: {},
83+
}));
84+
85+
describe('getTargetUnits', () => {
86+
const mockUnits: TargetUnit[] = [createMockUnit()];
87+
const mockFile =
88+
'data/weeklyFinancialReportsWorkflow/getTargetUnits/target-units-123.json';
89+
let writeJsonFileMock: Mock;
90+
let TargetUnitRepository: TargetUnitRepositoryMock;
91+
let endPool: Mock;
92+
let dateSpy: ReturnType<typeof vi.spyOn>;
93+
94+
beforeEach(async () => {
95+
dateSpy = vi.spyOn(Date, 'now').mockReturnValue(123);
96+
writeJsonFileMock = vi.mocked(writeJsonFile);
97+
writeJsonFileMock.mockClear();
98+
TargetUnitRepository = await setupTargetUnitRepositoryMock();
99+
endPool = vi.fn();
100+
setupRedminePoolMock(endPool);
101+
});
102+
103+
afterEach(() => {
104+
dateSpy.mockRestore();
105+
});
106+
107+
const mockRepo = (success = true) => {
108+
TargetUnitRepository.mockImplementation(() => ({
109+
getTargetUnits: success
110+
? vi.fn().mockResolvedValue(mockUnits)
111+
: vi.fn().mockRejectedValue(new Error('fail-get')),
112+
}));
113+
};
114+
115+
it('returns fileLink when successful', async () => {
116+
mockRepo(true);
117+
writeJsonFileMock.mockResolvedValue(undefined);
118+
const result = await getTargetUnits();
119+
120+
expect(result).toEqual({ fileLink: mockFile });
121+
expect(writeJsonFile).toHaveBeenCalledWith(mockFile, mockUnits);
122+
});
123+
124+
it('throws AppError when repo.getTargetUnits throws', async () => {
125+
mockRepo(false);
126+
writeJsonFileMock.mockResolvedValue(undefined);
127+
await expect(getTargetUnits()).rejects.toThrow(AppError);
128+
await expect(getTargetUnits()).rejects.toThrow(
129+
'Failed to get Target Units',
130+
);
131+
});
132+
133+
it('throws AppError when writeJsonFile throws', async () => {
134+
mockRepo(true);
135+
writeJsonFileMock.mockRejectedValue(new Error('fail-write'));
136+
await expect(getTargetUnits()).rejects.toThrow(AppError);
137+
await expect(getTargetUnits()).rejects.toThrow(
138+
'Failed to get Target Units',
139+
);
140+
});
141+
142+
it('always ends the Redmine pool', async () => {
143+
mockRepo(true);
144+
writeJsonFileMock.mockResolvedValue(undefined);
145+
await getTargetUnits();
146+
expect(endPool).toHaveBeenCalled();
147+
});
148+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { AppError } from '../../common/errors';
2+
import { writeJsonFile } from '../../common/fileUtils';
3+
import { RedminePool } from '../../common/RedminePool';
4+
import { redmineDatabaseConfig } from '../../configs/redmineDatabase';
5+
import { TargetUnitRepository } from '../../services/TargetUnit/TargetUnitRepository';
6+
7+
interface GetTargetUnitsResult {
8+
fileLink: string;
9+
}
10+
11+
export const getTargetUnits = async (): Promise<GetTargetUnitsResult> => {
12+
const redminePool = new RedminePool(redmineDatabaseConfig);
13+
14+
try {
15+
const pool = redminePool.getPool();
16+
17+
const repo = new TargetUnitRepository(pool);
18+
const result = await repo.getTargetUnits();
19+
const filename = `data/weeklyFinancialReportsWorkflow/getTargetUnits/target-units-${Date.now()}.json`;
20+
21+
await writeJsonFile(filename, result);
22+
23+
return { fileLink: filename };
24+
} catch (err) {
25+
const message = err instanceof Error ? err.message : String(err);
26+
27+
throw new AppError('Failed to get Target Units', message);
28+
} finally {
29+
await redminePool.endPool();
30+
}
31+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './getTargetUnits';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
vi.mock('fs', () => ({
2+
promises: {
3+
readFile: vi.fn(),
4+
writeFile: vi.fn(),
5+
mkdir: vi.fn(),
6+
},
7+
}));
8+
9+
import { promises as fs } from 'fs';
10+
import path from 'path';
11+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
12+
13+
import { FileUtilsError } from './errors';
14+
import { readJsonFile, writeJsonFile } from './fileUtils';
15+
16+
describe('fileUtils', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
});
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
25+
describe('readJsonFile', () => {
26+
test('reads and parses JSON file', async () => {
27+
vi.mocked(fs.readFile).mockResolvedValueOnce('{"a":1}');
28+
const result = await readJsonFile<{ a: number }>('test.json');
29+
30+
expect(result).toEqual({ a: 1 });
31+
expect(fs.readFile).toHaveBeenCalledWith('test.json', 'utf-8');
32+
});
33+
34+
test('throws FileUtilsError for invalid JSON', async () => {
35+
vi.mocked(fs.readFile).mockResolvedValueOnce('not-json');
36+
await expect(readJsonFile('bad.json')).rejects.toBeInstanceOf(
37+
FileUtilsError,
38+
);
39+
});
40+
41+
test('throws FileUtilsError if fs.readFile throws', async () => {
42+
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('fail'));
43+
await expect(readJsonFile('fail.json')).rejects.toBeInstanceOf(
44+
FileUtilsError,
45+
);
46+
});
47+
});
48+
49+
describe('writeJsonFile', () => {
50+
test('writes JSON file and creates directory', async () => {
51+
vi.mocked(fs.mkdir).mockResolvedValueOnce(undefined);
52+
vi.mocked(fs.writeFile).mockResolvedValueOnce(undefined);
53+
const filePath = 'dir/file.json';
54+
const data = { b: 2 };
55+
56+
await writeJsonFile(filePath, data);
57+
expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(filePath), {
58+
recursive: true,
59+
});
60+
expect(fs.writeFile).toHaveBeenCalledWith(
61+
filePath,
62+
JSON.stringify(data, null, 2),
63+
'utf-8',
64+
);
65+
});
66+
67+
test('throws FileUtilsError if fs.mkdir throws', async () => {
68+
vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('fail'));
69+
await expect(writeJsonFile('fail.json', {})).rejects.toBeInstanceOf(
70+
FileUtilsError,
71+
);
72+
});
73+
74+
test('throws FileUtilsError if fs.writeFile throws', async () => {
75+
vi.mocked(fs.mkdir).mockResolvedValueOnce(undefined);
76+
vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('fail'));
77+
await expect(writeJsonFile('fail2.json', {})).rejects.toBeInstanceOf(
78+
FileUtilsError,
79+
);
80+
});
81+
});
82+
});

workers/main/src/common/fileUtils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { promises as fs } from 'fs';
2+
import path from 'path';
3+
4+
import { FileUtilsError } from './errors';
5+
6+
export async function readJsonFile<T = object>(filePath: string): Promise<T> {
7+
try {
8+
const content = await fs.readFile(filePath, 'utf-8');
9+
const parsed = JSON.parse(content) as T;
10+
11+
return parsed;
12+
} catch {
13+
throw new FileUtilsError(
14+
`Failed to read or parse JSON file at "${filePath}"`,
15+
);
16+
}
17+
}
18+
19+
export async function writeJsonFile<T = object>(
20+
filePath: string,
21+
data: T,
22+
): Promise<void> {
23+
try {
24+
const content = JSON.stringify(data, null, 2);
25+
const dir = path.dirname(filePath);
26+
27+
await fs.mkdir(dir, { recursive: true });
28+
await fs.writeFile(filePath, content, 'utf-8');
29+
} catch {
30+
throw new FileUtilsError(`Failed to write JSON file at "${filePath}"`);
31+
}
32+
}

workers/main/src/workflows/weeklyFinancialReports/index.test.ts

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)