Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 4 additions & 0 deletions workers/main/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { GroupNameEnum } from '../configs';

export interface TargetUnit {
group_id: number;
group_name: string;
Expand All @@ -10,3 +12,5 @@ export interface TargetUnit {
rate?: number;
projectRate?: number;
}

export type GroupName = (typeof GroupNameEnum)[keyof typeof GroupNameEnum];
4 changes: 4 additions & 0 deletions workers/main/src/configs/weeklyFinancialReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum GroupNameEnum {
SD_REPORT = 'SD Weekly Financial Report',
ED_REPORT = 'ED Weekly Financial Report',
}
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');
});
});
11 changes: 7 additions & 4 deletions workers/main/src/services/TargetUnit/TargetUnitRepository.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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 { getTargetUnitsQuery } from './queries';
import { TargetUnitRow } from './types';

export class TargetUnitRepository implements ITargetUnitRepository {
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[]>(
getTargetUnitsQuery,
[groupName],
);

if (!Array.isArray(rows)) {
throw new TargetUnitRepositoryError('Query did not return an array');
Expand Down
84 changes: 43 additions & 41 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`;
export const getTargetUnitsQuery = `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 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,19 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import * as workflowModule from '@temporalio/workflow';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';

import { AppError } from '../../common/errors/AppError';
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' }),
}),
}));
// Define the type for the mocked getTargetUnits function
interface GetTargetUnitsMock extends Mock {
mockResolvedValueOnce: (value: { fileLink: string }) => void;
mockRejectedValueOnce: (error: Error) => void;
mockReset: () => void;
}

declare module '@temporalio/workflow' {
export function __getTargetUnitsMock(): GetTargetUnitsMock;
}

vi.mock('@temporalio/workflow', () => {
const getTargetUnitsMock = vi.fn();

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

describe('weeklyFinancialReportsWorkflow', () => {
it('returns the fileLink from getTargetUnits', async () => {
const result = await weeklyFinancialReportsWorkflow();
const getTargetUnitsMock = workflowModule.__getTargetUnitsMock();

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

it.each([[GroupNameEnum.SD_REPORT], [GroupNameEnum.ED_REPORT]])(
'returns the fileLink from getTargetUnits for group %s',
async (groupName: GroupNameEnum) => {
getTargetUnitsMock.mockResolvedValueOnce({
fileLink: `${groupName}-mocked-link.json`,
});
const result = await weeklyFinancialReportsWorkflow(groupName);

expect(result).toBe(`${groupName}-mocked-link.json`);
},
);

it('throws AppError for invalid group name', async () => {
await expect(
weeklyFinancialReportsWorkflow(
'INVALID_GROUP' as unknown as GroupNameEnum,
),
).rejects.toThrow(AppError);
await expect(
weeklyFinancialReportsWorkflow(
'INVALID_GROUP' as unknown as GroupNameEnum,
),
).rejects.toThrow('Invalid groupName: INVALID_GROUP');
});

expect(result).toBe('sub-dir/mocked-link.json');
it('propagates error from getTargetUnits', async () => {
getTargetUnitsMock.mockRejectedValueOnce(new Error('activity error'));
await expect(
weeklyFinancialReportsWorkflow(GroupNameEnum.SD_REPORT),
).rejects.toThrow('activity error');
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { proxyActivities } from '@temporalio/workflow';

import type * as activities from '../../activities/weeklyFinancialReports';
import { AppError } from '../../common/errors';
import { GroupName } from '../../common/types';
import { GroupNameEnum } from '../../configs/weeklyFinancialReport';

const { getTargetUnits } = proxyActivities<typeof activities>({
startToCloseTimeout: '10 minutes',
});

export async function weeklyFinancialReportsWorkflow(): Promise<string> {
const targetUnits = await getTargetUnits();
export async function weeklyFinancialReportsWorkflow(
groupName: GroupName,
): Promise<string> {
if (!(Object.values(GroupNameEnum) as GroupName[]).includes(groupName)) {
throw new AppError(
`Invalid groupName: ${groupName}`,
'weeklyFinancialReportsWorkflow',
);
}
const targetUnits = await getTargetUnits(groupName);

return targetUnits.fileLink;
}