Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { AppError } from '../../common/errors';
import { writeJsonFile } from '../../common/fileUtils';
import { RedminePool } from '../../common/RedminePool';
import { GroupNameEnum } from '../../configs/weeklyFinancialReport';
import { getTargetUnits } from './getTargetUnits';

type TargetUnit = {
Expand Down Expand Up @@ -80,6 +81,7 @@ vi.mock('../../common/fileUtils', () => ({
}));
vi.mock('../../configs/redmineDatabase', () => ({
redmineDatabaseConfig: {},
redmineDatabaseSchema: {},
}));

describe('getTargetUnits', () => {
Expand Down Expand Up @@ -112,10 +114,10 @@ describe('getTargetUnits', () => {
}));
};

it('returns fileLink when successful', async () => {
it('returns fileLink when successful (default group)', async () => {
mockRepo(true);
writeJsonFileMock.mockResolvedValue(undefined);
const result = await getTargetUnits();
const result = await getTargetUnits(GroupNameEnum.SD_REPORT);

expect(result).toEqual({ fileLink: mockFile });
expect(writeJsonFile).toHaveBeenCalledWith(mockFile, mockUnits);
Expand All @@ -124,25 +126,29 @@ describe('getTargetUnits', () => {
it('throws AppError when repo.getTargetUnits throws', async () => {
mockRepo(false);
writeJsonFileMock.mockResolvedValue(undefined);
await expect(getTargetUnits()).rejects.toThrow(AppError);
await expect(getTargetUnits()).rejects.toThrow(
await expect(getTargetUnits(GroupNameEnum.SD_REPORT)).rejects.toThrow(
AppError,
);
await expect(getTargetUnits(GroupNameEnum.SD_REPORT)).rejects.toThrow(
'Failed to get Target Units',
);
});

it('throws AppError when writeJsonFile throws', async () => {
mockRepo(true);
writeJsonFileMock.mockRejectedValue(new Error('fail-write'));
await expect(getTargetUnits()).rejects.toThrow(AppError);
await expect(getTargetUnits()).rejects.toThrow(
await expect(getTargetUnits(GroupNameEnum.SD_REPORT)).rejects.toThrow(
AppError,
);
await expect(getTargetUnits(GroupNameEnum.SD_REPORT)).rejects.toThrow(
'Failed to get Target Units',
);
});

it('always ends the Redmine pool', async () => {
mockRepo(true);
writeJsonFileMock.mockResolvedValue(undefined);
await getTargetUnits();
await getTargetUnits(GroupNameEnum.SD_REPORT);
expect(endPool).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { AppError } from '../../common/errors';
import { writeJsonFile } from '../../common/fileUtils';
import { RedminePool } from '../../common/RedminePool';
import { GroupName } from '../../common/types';
import { redmineDatabaseConfig } from '../../configs/redmineDatabase';
import { TargetUnitRepository } from '../../services/TargetUnit/TargetUnitRepository';

interface GetTargetUnitsResult {
fileLink: string;
}

export const getTargetUnits = async (): Promise<GetTargetUnitsResult> => {
export const getTargetUnits = async (
groupName: GroupName,
): Promise<GetTargetUnitsResult> => {
const redminePool = new RedminePool(redmineDatabaseConfig);

try {
const pool = redminePool.getPool();

const repo = new TargetUnitRepository(pool);
const result = await repo.getTargetUnits();
const result = await repo.getTargetUnits(groupName);
const filename = `data/weeklyFinancialReportsWorkflow/getTargetUnits/target-units-${Date.now()}.json`;

await writeJsonFile(filename, result);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TargetUnit } from '../../common/types';
import { GroupName, TargetUnit } from '../../common/types';

export interface ITargetUnitRepository {
getTargetUnits(): Promise<TargetUnit[]>;
getTargetUnits(groupName: GroupName): Promise<TargetUnit[]>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';

import { TargetUnitRepositoryError } from '../../common/errors';
import { TargetUnit } from '../../common/types';
import { GroupNameEnum } from '../../configs/weeklyFinancialReport';
import { TargetUnitRepository } from './TargetUnitRepository';
import { TargetUnitRow } from './types';

Expand Down Expand Up @@ -37,7 +38,7 @@ describe('TargetUnitRepository', () => {
];

mockPool.query.mockResolvedValueOnce([rows]);
const result = await repo.getTargetUnits();
const result = await repo.getTargetUnits(GroupNameEnum.SD_REPORT);

expect(result).toEqual<Partial<TargetUnit>[]>([
{
Expand All @@ -55,13 +56,15 @@ describe('TargetUnitRepository', () => {

it('throws TargetUnitRepositoryError if query does not return array', async () => {
mockPool.query.mockResolvedValueOnce([null]);
await expect(repo.getTargetUnits()).rejects.toThrow(
await expect(repo.getTargetUnits(GroupNameEnum.SD_REPORT)).rejects.toThrow(
TargetUnitRepositoryError,
);
});

it('throws TargetUnitRepositoryError on query error', async () => {
mockPool.query.mockRejectedValueOnce(new Error('db error'));
await expect(repo.getTargetUnits()).rejects.toThrowError('db error');
await expect(
repo.getTargetUnits(GroupNameEnum.SD_REPORT),
).rejects.toThrowError('db error');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Pool } from 'mysql2/promise';

import { TargetUnitRepositoryError } from '../../common/errors';
import { TargetUnit } from '../../common/types';
import { GroupName, TargetUnit } from '../../common/types';
import { ITargetUnitRepository } from './ITargetUnitRepository';
import { TARGET_UNITS_QUERY } from './queries';
import { TargetUnitRow } from './types';
Expand Down Expand Up @@ -33,9 +33,12 @@ export class TargetUnitRepository implements ITargetUnitRepository {
total_hours: Number(total_hours),
});

async getTargetUnits(): Promise<TargetUnit[]> {
async getTargetUnits(groupName: GroupName): Promise<TargetUnit[]> {
try {
const [rows] = await this.pool.query<TargetUnitRow[]>(TARGET_UNITS_QUERY);
const [rows] = await this.pool.query<TargetUnitRow[]>(
TARGET_UNITS_QUERY,
[groupName],
);

if (!Array.isArray(rows)) {
throw new TargetUnitRepositoryError('Query did not return an array');
Expand Down
82 changes: 42 additions & 40 deletions workers/main/src/services/TargetUnit/queries.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,43 @@
export const TARGET_UNITS_QUERY = `SELECT
group_id,
group_name,
project_id,
project_name,
user_id,
username,
DATE_FORMAT(spent_on, '%Y-%m-%d') AS spent_on,
SUM(total_hours) AS total_hours
FROM (
SELECT
g.id AS group_id,
g.lastname AS group_name,
p.id AS project_id,
p.name AS project_name,
te.user_id AS user_id,
CONCAT(u.firstname, ' ', u.lastname) AS username,
te.spent_on AS spent_on,
te.hours AS total_hours
FROM users AS g
JOIN members AS m ON m.user_id = g.id
JOIN projects AS p ON p.id = m.project_id
JOIN time_entries te ON te.project_id = p.id
JOIN users AS u ON u.id = te.user_id
WHERE g.type = 'Group'
AND te.spent_on BETWEEN DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 7 DAY)
AND DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 1 DAY)
) t
GROUP BY
group_id,
group_name,
project_id,
project_name,
user_id,
username,
spent_on
ORDER BY
group_name ASC,
project_name ASC,
username ASC,
spent_on ASC`;
group_id,
group_name,
project_id,
project_name,
user_id,
username,
DATE_FORMAT(spent_on, '%Y-%m-%d') AS spent_on,
SUM(total_hours) AS total_hours
FROM (
SELECT
g.id AS group_id,
g.lastname AS group_name,
p.id AS project_id,
p.name AS project_name,
te.user_id AS user_id,
CONCAT(u.firstname, ' ', u.lastname) AS username,
te.spent_on AS spent_on,
te.hours AS total_hours
FROM users AS g
JOIN custom_values as cv ON cv.customized_id = g.id
JOIN members AS m ON m.user_id = g.id
JOIN projects AS p ON p.id = m.project_id
JOIN time_entries te ON te.project_id = p.id
JOIN users AS u ON u.id = te.user_id
WHERE g.type = 'Group'
AND cv.customized_type = 'Principal' and cv.value = ?
AND te.spent_on BETWEEN DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 7 DAY)
AND DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 1 DAY)
) t
GROUP BY
group_id,
group_name,
project_id,
project_name,
user_id,
username,
spent_on
ORDER BY
group_name ASC,
project_name ASC,
username ASC,
spent_on ASC`;
Original file line number Diff line number Diff line change
@@ -1,25 +1,83 @@
import { describe, expect, it, vi } from 'vitest';
import * as workflowModule from '@temporalio/workflow';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { AppError } from '../../common/errors';
import { GroupNameEnum } from '../../configs/weeklyFinancialReport';
import { weeklyFinancialReportsWorkflow } from './weeklyFinancialReports.workflow';

vi.mock('@temporalio/workflow', () => ({
proxyActivities: () => ({
getTargetUnits: vi
.fn()
.mockResolvedValue({ fileLink: 'sub-dir/mocked-link.json' }),
fetchFinancialAppData: vi
.fn()
.mockResolvedValue({ fileLink: 'sub-dir/mocked-link.json' }),
}),
}));
vi.mock('@temporalio/workflow', () => {
const getTargetUnitsMock = vi.fn();
const fetchFinancialAppDataMock = vi.fn();

return {
proxyActivities: () => ({
getTargetUnits: getTargetUnitsMock,
fetchFinancialAppData: fetchFinancialAppDataMock,
}),
__getTargetUnitsMock: () => getTargetUnitsMock,
__getFetchFinancialAppDataMock: () => fetchFinancialAppDataMock,
};
});

describe('weeklyFinancialReportsWorkflow', () => {
it('returns the fileLink from getTargetUnits', async () => {
type WorkflowModuleWithMock = typeof workflowModule & {
__getTargetUnitsMock: () => ReturnType<typeof vi.fn>;
__getFetchFinancialAppDataMock: () => ReturnType<typeof vi.fn>;
};
const getTargetUnitsMock = (
workflowModule as WorkflowModuleWithMock
).__getTargetUnitsMock();
const fetchFinancialAppDataMock = (
workflowModule as WorkflowModuleWithMock
).__getFetchFinancialAppDataMock();

beforeEach(() => {
getTargetUnitsMock.mockReset();
fetchFinancialAppDataMock.mockReset();
});

it('throws AppError for invalid group name', async () => {
const allowedValues = Object.values(GroupNameEnum).join('", "');
const expectedMessage = `Invalid groupName parameter: INVALID_GROUP. Allowed values: "${allowedValues}"`;

await expect(
weeklyFinancialReportsWorkflow(
'INVALID_GROUP' as unknown as GroupNameEnum,
),
).rejects.toThrow(AppError);
await expect(
weeklyFinancialReportsWorkflow(
'INVALID_GROUP' as unknown as GroupNameEnum,
),
).rejects.toThrow(expectedMessage);
});

it('propagates error from getTargetUnits', async () => {
getTargetUnitsMock.mockRejectedValueOnce(new Error('activity error'));
await expect(
weeklyFinancialReportsWorkflow(GroupNameEnum.SD_REPORT),
).rejects.toThrow('activity error');
});

it('propagates error from fetchFinancialAppData', async () => {
getTargetUnitsMock.mockResolvedValueOnce({ fileLink: 'file.json' });
fetchFinancialAppDataMock.mockRejectedValueOnce(new Error('fetch error'));
await expect(
weeklyFinancialReportsWorkflow(GroupNameEnum.SD_REPORT),
).rejects.toThrow('fetch error');
});

it('returns fileLink on success', async () => {
getTargetUnitsMock.mockResolvedValueOnce({ fileLink: 'file.json' });
fetchFinancialAppDataMock.mockResolvedValueOnce({
fileLink: 'result.json',
});
const result = await weeklyFinancialReportsWorkflow(
GroupNameEnum.ED_REPORT,
GroupNameEnum.SD_REPORT,
);

expect(result).toBe('sub-dir/mocked-link.json');
expect(result).toBe('result.json');
expect(getTargetUnitsMock).toHaveBeenCalledWith(GroupNameEnum.SD_REPORT);
expect(fetchFinancialAppDataMock).toHaveBeenCalledWith('file.json');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ export async function weeklyFinancialReportsWorkflow(
): Promise<string> {
if (!(Object.values(GroupNameEnum) as GroupName[]).includes(groupName)) {
throw new AppError(
`Invalid groupName paramter: ${groupName}. Allowed values: "${Object.values(GroupNameEnum).join('", "')}"`,
`Invalid groupName parameter: ${groupName}. Allowed values: "${Object.values(GroupNameEnum).join('", "')}"`,
'weeklyFinancialReportsWorkflow',
);
}
const targetUnits = await getTargetUnits();
const targetUnits = await getTargetUnits(groupName);
const finData = await fetchFinancialAppData(targetUnits.fileLink);

return finData.fileLink;
Expand Down