From b679e12018b771c2ff7e51860c8c26ceb087a794 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Tue, 3 Jun 2025 18:36:32 +0200 Subject: [PATCH 01/15] feat: Implement RedminePool class with unit tests - Added `RedminePool.ts` to manage MySQL connection pooling, including methods for pool creation and termination. - Created `RedminePool.test.ts` to validate the functionality of the `RedminePool` class, ensuring proper pool management and error handling. These additions enhance database connection management and improve test coverage for the Redmine integration. --- workers/main/src/common/RedminePool.test.ts | 61 +++++++++++++++++++++ workers/main/src/common/RedminePool.ts | 33 +++++++++++ 2 files changed, 94 insertions(+) create mode 100644 workers/main/src/common/RedminePool.test.ts create mode 100644 workers/main/src/common/RedminePool.ts diff --git a/workers/main/src/common/RedminePool.test.ts b/workers/main/src/common/RedminePool.test.ts new file mode 100644 index 0000000..45c6428 --- /dev/null +++ b/workers/main/src/common/RedminePool.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RedminePool } from './RedminePool'; +import type { PoolOptions } from 'mysql2/promise'; +import * as mysql from 'mysql2/promise'; +import type { Mock } from 'vitest'; + +vi.mock('mysql2/promise', () => { + const poolMock = { + end: vi.fn().mockResolvedValue(undefined), + }; + return { + createPool: vi.fn(() => poolMock), + Pool: class {}, + }; +}); + +describe('RedminePool', () => { + const credentials: PoolOptions = { host: 'localhost', user: 'root', database: 'test' }; + let poolInstance: RedminePool; + let poolMock: any; + + beforeEach(() => { + poolInstance = new RedminePool(credentials); + poolMock = (mysql.createPool as unknown as Mock).mock.results[0].value; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should create a pool on construction', () => { + expect(mysql.createPool).toHaveBeenCalledWith(credentials); + expect(poolInstance.getPool()).toBe(poolMock); + }); + + it('should recreate the pool if pool is null', () => { + (poolInstance as any).pool = null; + const pool = poolInstance.getPool(); + expect(mysql.createPool).toHaveBeenCalledTimes(2); + expect(pool).toBe(poolMock); + }); + + it('should recreate the pool if poolEnded is true', () => { + (poolInstance as any).poolEnded = true; + const pool = poolInstance.getPool(); + expect(mysql.createPool).toHaveBeenCalledTimes(2); + expect(pool).toBe(poolMock); + }); + + it('should end the pool and set poolEnded to true', async () => { + await poolInstance.endPool(); + expect(poolMock.end).toHaveBeenCalled(); + expect((poolInstance as any).poolEnded).toBe(true); + }); + + it('should not end the pool if already ended', async () => { + (poolInstance as any).poolEnded = true; + await poolInstance.endPool(); + expect(poolMock.end).not.toHaveBeenCalled(); + }); +}); diff --git a/workers/main/src/common/RedminePool.ts b/workers/main/src/common/RedminePool.ts new file mode 100644 index 0000000..b56b9e4 --- /dev/null +++ b/workers/main/src/common/RedminePool.ts @@ -0,0 +1,33 @@ +import * as mysql from 'mysql2/promise'; +import { Pool, PoolOptions } from 'mysql2/promise'; + +export class RedminePool { + private pool: Pool | null = null; + private credentials: PoolOptions; + private poolEnded = false; + + constructor(credentials: PoolOptions) { + this.credentials = credentials; + this.createPool(); + } + + private createPool() { + this.pool = mysql.createPool(this.credentials); + this.poolEnded = false; + } + + public getPool(): Pool { + if (!this.pool || this.poolEnded) { + this.createPool(); + } + + return this.pool!; + } + + async endPool() { + if (this.pool && !this.poolEnded) { + await this.pool.end(); + this.poolEnded = true; + } + } +} From 67eefc5e4915b9de880d1e88fa9bc9e10c74ed03 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Tue, 3 Jun 2025 18:46:30 +0200 Subject: [PATCH 02/15] refactor: Improve RedminePool test structure and type safety - Updated the `RedminePool.test.ts` file to enhance type safety by defining a `PoolMock` interface. - Refactored the test setup to improve readability and maintainability, including clearer type assertions for pool properties. - Adjusted the formatting of the credentials object for better clarity. These changes improve the robustness of the tests for the `RedminePool` class, ensuring better type checking and organization. --- workers/main/src/common/RedminePool.test.ts | 33 +++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/workers/main/src/common/RedminePool.test.ts b/workers/main/src/common/RedminePool.test.ts index 45c6428..5ff2065 100644 --- a/workers/main/src/common/RedminePool.test.ts +++ b/workers/main/src/common/RedminePool.test.ts @@ -1,27 +1,38 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { RedminePool } from './RedminePool'; import type { PoolOptions } from 'mysql2/promise'; import * as mysql from 'mysql2/promise'; import type { Mock } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RedminePool } from './RedminePool'; vi.mock('mysql2/promise', () => { const poolMock = { end: vi.fn().mockResolvedValue(undefined), }; + return { createPool: vi.fn(() => poolMock), Pool: class {}, }; }); +interface PoolMock { + end: Mock; +} + describe('RedminePool', () => { - const credentials: PoolOptions = { host: 'localhost', user: 'root', database: 'test' }; + const credentials: PoolOptions = { + host: 'localhost', + user: 'root', + database: 'test', + }; let poolInstance: RedminePool; - let poolMock: any; + let poolMock: PoolMock; beforeEach(() => { poolInstance = new RedminePool(credentials); - poolMock = (mysql.createPool as unknown as Mock).mock.results[0].value; + poolMock = (mysql.createPool as unknown as Mock).mock.results[0] + .value as PoolMock; }); afterEach(() => { @@ -34,15 +45,17 @@ describe('RedminePool', () => { }); it('should recreate the pool if pool is null', () => { - (poolInstance as any).pool = null; + (poolInstance as unknown as { pool: PoolMock | null }).pool = null; const pool = poolInstance.getPool(); + expect(mysql.createPool).toHaveBeenCalledTimes(2); expect(pool).toBe(poolMock); }); it('should recreate the pool if poolEnded is true', () => { - (poolInstance as any).poolEnded = true; + (poolInstance as unknown as { poolEnded: boolean }).poolEnded = true; const pool = poolInstance.getPool(); + expect(mysql.createPool).toHaveBeenCalledTimes(2); expect(pool).toBe(poolMock); }); @@ -50,11 +63,13 @@ describe('RedminePool', () => { it('should end the pool and set poolEnded to true', async () => { await poolInstance.endPool(); expect(poolMock.end).toHaveBeenCalled(); - expect((poolInstance as any).poolEnded).toBe(true); + expect((poolInstance as unknown as { poolEnded: boolean }).poolEnded).toBe( + true, + ); }); it('should not end the pool if already ended', async () => { - (poolInstance as any).poolEnded = true; + (poolInstance as unknown as { poolEnded: boolean }).poolEnded = true; await poolInstance.endPool(); expect(poolMock.end).not.toHaveBeenCalled(); }); From 82062f3f2766976f2eb95db0c62fc38c72680b01 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Tue, 3 Jun 2025 19:24:24 +0200 Subject: [PATCH 03/15] fix: Enhance error handling in RedminePool class and tests - Updated the `RedminePool` class to include error handling during pool creation and termination, ensuring that meaningful error messages are thrown when failures occur. - Added new test cases in `RedminePool.test.ts` to validate the graceful handling of pool creation and termination errors. These changes improve the robustness of the `RedminePool` class by ensuring it can handle errors effectively, enhancing overall reliability. --- workers/main/src/common/RedminePool.test.ts | 15 +++++++++++++++ workers/main/src/common/RedminePool.ts | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/workers/main/src/common/RedminePool.test.ts b/workers/main/src/common/RedminePool.test.ts index 5ff2065..fa3d152 100644 --- a/workers/main/src/common/RedminePool.test.ts +++ b/workers/main/src/common/RedminePool.test.ts @@ -73,4 +73,19 @@ describe('RedminePool', () => { await poolInstance.endPool(); expect(poolMock.end).not.toHaveBeenCalled(); }); + + it('should handle pool creation errors gracefully', () => { + (mysql.createPool as Mock).mockImplementationOnce(() => { + throw new Error('Connection failed'); + }); + expect(() => new RedminePool(credentials)).toThrow('Connection failed'); + }); + + it('should handle pool.end() errors gracefully', async () => { + poolMock.end.mockRejectedValueOnce(new Error('End failed')); + await expect(poolInstance.endPool()).rejects.toThrow('End failed'); + expect((poolInstance as unknown as { poolEnded: boolean }).poolEnded).toBe( + true, + ); + }); }); diff --git a/workers/main/src/common/RedminePool.ts b/workers/main/src/common/RedminePool.ts index b56b9e4..7092700 100644 --- a/workers/main/src/common/RedminePool.ts +++ b/workers/main/src/common/RedminePool.ts @@ -8,7 +8,13 @@ export class RedminePool { constructor(credentials: PoolOptions) { this.credentials = credentials; - this.createPool(); + try { + this.createPool(); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + + throw new Error(`RedminePool initialization failed: ${errMsg}`); + } } private createPool() { @@ -26,8 +32,15 @@ export class RedminePool { async endPool() { if (this.pool && !this.poolEnded) { - await this.pool.end(); - this.poolEnded = true; + try { + await this.pool.end(); + this.poolEnded = true; + } catch (error) { + this.poolEnded = true; + const errMsg = error instanceof Error ? error.message : String(error); + + throw new Error(`Failed to end MySQL connection pool: ${errMsg}`); + } } } } From 8f92d905c81be0e2b0674b329cefa1b905c77a97 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 4 Jun 2025 15:54:10 +0200 Subject: [PATCH 04/15] feat: Implement Redmine repository - Added `ProjectUnit` interface to define the structure of Project Unit data. - Created `IRedmineRepository` interface for fetching project units. - Implemented `RedmineRepository` class to handle database queries and map results to `ProjectUnit`. - Introduced SQL query for retrieving project units based on time entries. - Developed `RedmineService` class to provide a service layer for accessing project units. Implements Redmine project unit management Adds functionality to retrieve and manage project units from Redmine. Introduces a repository pattern for data access, a service layer for business logic, and a data transfer object for representing project unit information. This provides a structured way to interact with the Redmine database and retrieve project-related data. --- workers/main/src/common/types.ts | 12 ++++ .../services/redmine/IRedmineRepository.ts | 5 ++ .../src/services/redmine/RedmineRepository.ts | 71 +++++++++++++++++++ .../src/services/redmine/RedmineService.ts | 10 +++ workers/main/src/services/redmine/queries.ts | 28 ++++++++ workers/main/src/services/redmine/types.ts | 24 +++++++ 6 files changed, 150 insertions(+) create mode 100644 workers/main/src/common/types.ts create mode 100644 workers/main/src/services/redmine/IRedmineRepository.ts create mode 100644 workers/main/src/services/redmine/RedmineRepository.ts create mode 100644 workers/main/src/services/redmine/RedmineService.ts create mode 100644 workers/main/src/services/redmine/queries.ts create mode 100644 workers/main/src/services/redmine/types.ts diff --git a/workers/main/src/common/types.ts b/workers/main/src/common/types.ts new file mode 100644 index 0000000..9e21c4b --- /dev/null +++ b/workers/main/src/common/types.ts @@ -0,0 +1,12 @@ +export interface ProjectUnit { + group_id: number; + group_name: string; + project_id: number; + project_name: string; + user_id: number; + username: string; + spent_on: string; + total_hours: number; + rate?: number; + projectRate?: number; +} diff --git a/workers/main/src/services/redmine/IRedmineRepository.ts b/workers/main/src/services/redmine/IRedmineRepository.ts new file mode 100644 index 0000000..2d3eece --- /dev/null +++ b/workers/main/src/services/redmine/IRedmineRepository.ts @@ -0,0 +1,5 @@ +import { ProjectUnit } from '../../common/types'; + +export interface IRedmineRepository { + getProjectUnits(): Promise; +} diff --git a/workers/main/src/services/redmine/RedmineRepository.ts b/workers/main/src/services/redmine/RedmineRepository.ts new file mode 100644 index 0000000..2a1e85a --- /dev/null +++ b/workers/main/src/services/redmine/RedmineRepository.ts @@ -0,0 +1,71 @@ +import { Pool } from 'mysql2/promise'; + +import { ProjectUnit } from '../../common/types'; +import { IRedmineRepository } from './IRedmineRepository'; +import { PROJECT_UNITS_QUERY } from './queries'; +import { IPoolProvider, ProjectUnitRow } from './types'; + +export class RedmineRepositoryError extends Error { + constructor( + message: string, + public originalError?: unknown, + ) { + super(message); + this.name = 'RedmineRepositoryError'; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, RedmineRepositoryError); + } + } +} + +export class RedmineRepository implements IRedmineRepository { + private readonly pool: Pool; + + constructor(poolProvider: IPoolProvider) { + this.pool = poolProvider.getPool(); + } + + private static mapRowToProjectUnit( + this: void, + { + group_id, + group_name, + project_id, + project_name, + user_id, + username, + spent_on, + total_hours, + }: ProjectUnitRow, + ): ProjectUnit { + return { + group_id: Number(group_id), + group_name: String(group_name), + project_id: Number(project_id), + project_name: String(project_name), + user_id: Number(user_id), + username: String(username), + spent_on: String(spent_on), + total_hours: Number(total_hours), + }; + } + + async getProjectUnits(): Promise { + try { + const [rows] = + await this.pool.query(PROJECT_UNITS_QUERY); + + if (!Array.isArray(rows)) { + throw new RedmineRepositoryError('Query did not return an array'); + } + + return rows.map(RedmineRepository.mapRowToProjectUnit); + } catch (error) { + console.error('RedmineRepository.getProjectUnits error:', error); + throw new RedmineRepositoryError( + `RedmineRepository.getProjectUnits failed: ${(error as Error).message}`, + error, + ); + } + } +} diff --git a/workers/main/src/services/redmine/RedmineService.ts b/workers/main/src/services/redmine/RedmineService.ts new file mode 100644 index 0000000..1062967 --- /dev/null +++ b/workers/main/src/services/redmine/RedmineService.ts @@ -0,0 +1,10 @@ +import { ProjectUnit } from '../../common/types'; +import { IRedmineRepository } from './IRedmineRepository'; + +export class RedmineService { + constructor(private repo: IRedmineRepository) {} + + async getProjectUnits(): Promise { + return this.repo.getProjectUnits(); + } +} diff --git a/workers/main/src/services/redmine/queries.ts b/workers/main/src/services/redmine/queries.ts new file mode 100644 index 0000000..bde66ad --- /dev/null +++ b/workers/main/src/services/redmine/queries.ts @@ -0,0 +1,28 @@ +export const PROJECT_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 >= CURDATE() - INTERVAL 7 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`; diff --git a/workers/main/src/services/redmine/types.ts b/workers/main/src/services/redmine/types.ts new file mode 100644 index 0000000..1431861 --- /dev/null +++ b/workers/main/src/services/redmine/types.ts @@ -0,0 +1,24 @@ +import { Pool, RowDataPacket } from 'mysql2/promise'; + +export interface ProjectUnitRow extends RowDataPacket { + group_id: number; + group_name: string; + project_id: number; + project_name: string; + user_id: number; + username: string; + spent_on: string; + total_hours: number; +} + +export interface IPoolProvider { + getPool(): Pool; +} + +export type ProjectUnitsResult = { + fileLink: string; +}; + +export type EmployeeRatesResult = { + fileLink: string; +}; From c1072655850cc8469f81c1b1d897eae669431873 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 4 Jun 2025 16:00:20 +0200 Subject: [PATCH 05/15] fix: Update SQL query for time entries in Redmine integration - Modified the SQL query in `queries.ts` to adjust the date range for fetching time entries. - The new query now correctly calculates the start and end dates for the past week, ensuring accurate data retrieval. This change enhances the accuracy of time entry data fetched from the Redmine API, improving reporting and analysis capabilities. --- workers/main/src/services/redmine/queries.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/workers/main/src/services/redmine/queries.ts b/workers/main/src/services/redmine/queries.ts index bde66ad..da9dc0d 100644 --- a/workers/main/src/services/redmine/queries.ts +++ b/workers/main/src/services/redmine/queries.ts @@ -22,7 +22,10 @@ FROM ( 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 >= CURDATE() - INTERVAL 7 DAY + WHERE + g.type='Group' AND + te.spent_on >= DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 7 DAY), INTERVAL 0 DAY) AND + te.spent_on <= DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 6 - WEEKDAY(CURDATE()) DAY), INTERVAL 7 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`; From b49ce63147cb5b94a477fe569a464d0db831d831 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 4 Jun 2025 16:26:11 +0200 Subject: [PATCH 06/15] test: Add unit tests for RedmineRepository and RedmineService - Introduced `RedmineRepository.test.ts` to validate the functionality of the `RedmineRepository` class, including tests for mapping rows to `ProjectUnit` and error handling. - Created `RedmineService.test.ts` to ensure the `RedmineService` correctly retrieves project units from the repository. These additions enhance test coverage for the Redmine integration, ensuring the reliability of data retrieval and error management in the repository and service layers. --- .../redmine/RedmineRepository.test.ts | 70 +++++++++++++++++++ .../services/redmine/RedmineService.test.ts | 47 +++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 workers/main/src/services/redmine/RedmineRepository.test.ts create mode 100644 workers/main/src/services/redmine/RedmineService.test.ts diff --git a/workers/main/src/services/redmine/RedmineRepository.test.ts b/workers/main/src/services/redmine/RedmineRepository.test.ts new file mode 100644 index 0000000..13b0676 --- /dev/null +++ b/workers/main/src/services/redmine/RedmineRepository.test.ts @@ -0,0 +1,70 @@ +import { Pool } from 'mysql2/promise'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { ProjectUnit } from '../../common/types'; +import { RedmineRepository, RedmineRepositoryError } from './RedmineRepository'; +import { IPoolProvider, ProjectUnitRow } from './types'; + +describe('RedmineRepository', () => { + let mockPool: { query: Mock }; + let mockPoolProvider: IPoolProvider; + let repo: RedmineRepository; + + beforeEach(() => { + mockPool = { + query: vi.fn(), + }; + mockPoolProvider = { + getPool: () => mockPool as unknown as Pool, + }; + repo = new RedmineRepository(mockPoolProvider); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('maps rows to ProjectUnit and returns them', async () => { + const rows: ProjectUnitRow[] = [ + { + group_id: 1, + group_name: 'Group', + project_id: 2, + project_name: 'Project', + user_id: 3, + username: 'User', + spent_on: '2024-06-01', + total_hours: 8, + constructor: { name: 'RowDataPacket' }, + } as ProjectUnitRow, + ]; + + mockPool.query.mockResolvedValueOnce([rows]); + const result = await repo.getProjectUnits(); + + expect(result).toEqual[]>([ + { + group_id: 1, + group_name: 'Group', + project_id: 2, + project_name: 'Project', + user_id: 3, + username: 'User', + spent_on: '2024-06-01', + total_hours: 8, + }, + ]); + }); + + it('throws RedmineRepositoryError if query does not return array', async () => { + mockPool.query.mockResolvedValueOnce([null]); + await expect(repo.getProjectUnits()).rejects.toThrow( + RedmineRepositoryError, + ); + }); + + it('throws RedmineRepositoryError on query error', async () => { + mockPool.query.mockRejectedValueOnce(new Error('db error')); + await expect(repo.getProjectUnits()).rejects.toThrow('db error'); + }); +}); diff --git a/workers/main/src/services/redmine/RedmineService.test.ts b/workers/main/src/services/redmine/RedmineService.test.ts new file mode 100644 index 0000000..2d880bc --- /dev/null +++ b/workers/main/src/services/redmine/RedmineService.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { ProjectUnit } from '../../common/types'; +import { RedmineService } from './RedmineService'; + +const createProjectUnit = ( + overrides: Partial = {}, +): ProjectUnit => ({ + group_id: 1, + group_name: 'Group', + project_id: 2, + project_name: 'Project', + user_id: 3, + username: 'User', + spent_on: '2024-06-01', + total_hours: 8, + ...overrides, +}); + +const createMockRepo = () => ({ + getProjectUnits: vi.fn(), +}); + +describe('RedmineService', () => { + let mockRepo: { getProjectUnits: Mock }; + let service: RedmineService; + + beforeEach(() => { + mockRepo = createMockRepo(); + service = new RedmineService(mockRepo); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return project units from the repository', async () => { + const units = [createProjectUnit()]; + + mockRepo.getProjectUnits.mockResolvedValueOnce(units); + + const result = await service.getProjectUnits(); + + expect(result).toEqual(units); + expect(mockRepo.getProjectUnits).toHaveBeenCalledTimes(1); + }); +}); From 7875d77a397bf349394e3341bf41bed8fa70e6fe Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 4 Jun 2025 19:00:56 +0200 Subject: [PATCH 07/15] Rename RedmineService and associated components to TargetUnitService This change replaces the RedmineService and its related components with TargetUnitService to align with updated domain terminology. All references, files, and test cases have been renamed or updated to maintain consistency. --- .../ITargetUnitRepository.ts} | 2 +- .../TargetUnitRepository.test.ts} | 25 ++++++++++-------- .../TargetUnitRepository.ts} | 26 +++++++++---------- .../TargetUnitService.test.ts} | 8 +++--- .../services/TargetUnit/TargetUnitService.ts | 10 +++++++ .../{redmine => TargetUnit}/queries.ts | 6 ++--- .../services/{redmine => TargetUnit}/types.ts | 4 +-- .../src/services/redmine/RedmineService.ts | 10 ------- 8 files changed, 47 insertions(+), 44 deletions(-) rename workers/main/src/services/{redmine/IRedmineRepository.ts => TargetUnit/ITargetUnitRepository.ts} (70%) rename workers/main/src/services/{redmine/RedmineRepository.test.ts => TargetUnit/TargetUnitRepository.test.ts} (69%) rename workers/main/src/services/{redmine/RedmineRepository.ts => TargetUnit/TargetUnitRepository.ts} (58%) rename workers/main/src/services/{redmine/RedmineService.test.ts => TargetUnit/TargetUnitService.test.ts} (84%) create mode 100644 workers/main/src/services/TargetUnit/TargetUnitService.ts rename workers/main/src/services/{redmine => TargetUnit}/queries.ts (77%) rename workers/main/src/services/{redmine => TargetUnit}/types.ts (80%) delete mode 100644 workers/main/src/services/redmine/RedmineService.ts diff --git a/workers/main/src/services/redmine/IRedmineRepository.ts b/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts similarity index 70% rename from workers/main/src/services/redmine/IRedmineRepository.ts rename to workers/main/src/services/TargetUnit/ITargetUnitRepository.ts index 2d3eece..27e642f 100644 --- a/workers/main/src/services/redmine/IRedmineRepository.ts +++ b/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts @@ -1,5 +1,5 @@ import { ProjectUnit } from '../../common/types'; -export interface IRedmineRepository { +export interface ITargetUnitRepository { getProjectUnits(): Promise; } diff --git a/workers/main/src/services/redmine/RedmineRepository.test.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts similarity index 69% rename from workers/main/src/services/redmine/RedmineRepository.test.ts rename to workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts index 13b0676..8077f66 100644 --- a/workers/main/src/services/redmine/RedmineRepository.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts @@ -2,13 +2,16 @@ import { Pool } from 'mysql2/promise'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { ProjectUnit } from '../../common/types'; -import { RedmineRepository, RedmineRepositoryError } from './RedmineRepository'; -import { IPoolProvider, ProjectUnitRow } from './types'; +import { + TargetUnitRepository, + TargetUnitRepositoryError, +} from './TargetUnitRepository'; +import { IPoolProvider, TargetUnitRow } from './types'; -describe('RedmineRepository', () => { +describe('TargetUnitRepository', () => { let mockPool: { query: Mock }; let mockPoolProvider: IPoolProvider; - let repo: RedmineRepository; + let repo: TargetUnitRepository; beforeEach(() => { mockPool = { @@ -17,15 +20,15 @@ describe('RedmineRepository', () => { mockPoolProvider = { getPool: () => mockPool as unknown as Pool, }; - repo = new RedmineRepository(mockPoolProvider); + repo = new TargetUnitRepository(mockPoolProvider); }); afterEach(() => { vi.clearAllMocks(); }); - it('maps rows to ProjectUnit and returns them', async () => { - const rows: ProjectUnitRow[] = [ + it('maps rows to TargetUnit and returns them', async () => { + const rows: TargetUnitRow[] = [ { group_id: 1, group_name: 'Group', @@ -36,7 +39,7 @@ describe('RedmineRepository', () => { spent_on: '2024-06-01', total_hours: 8, constructor: { name: 'RowDataPacket' }, - } as ProjectUnitRow, + } as TargetUnitRow, ]; mockPool.query.mockResolvedValueOnce([rows]); @@ -56,14 +59,14 @@ describe('RedmineRepository', () => { ]); }); - it('throws RedmineRepositoryError if query does not return array', async () => { + it('throws TargetUnitRepositoryError if query does not return array', async () => { mockPool.query.mockResolvedValueOnce([null]); await expect(repo.getProjectUnits()).rejects.toThrow( - RedmineRepositoryError, + TargetUnitRepositoryError, ); }); - it('throws RedmineRepositoryError on query error', async () => { + it('throws TargetUnitRepositoryError on query error', async () => { mockPool.query.mockRejectedValueOnce(new Error('db error')); await expect(repo.getProjectUnits()).rejects.toThrow('db error'); }); diff --git a/workers/main/src/services/redmine/RedmineRepository.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts similarity index 58% rename from workers/main/src/services/redmine/RedmineRepository.ts rename to workers/main/src/services/TargetUnit/TargetUnitRepository.ts index 2a1e85a..7913d4d 100644 --- a/workers/main/src/services/redmine/RedmineRepository.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts @@ -1,24 +1,24 @@ import { Pool } from 'mysql2/promise'; import { ProjectUnit } from '../../common/types'; -import { IRedmineRepository } from './IRedmineRepository'; +import { ITargetUnitRepository } from './ITargetUnitRepository'; import { PROJECT_UNITS_QUERY } from './queries'; -import { IPoolProvider, ProjectUnitRow } from './types'; +import { IPoolProvider, TargetUnitRow } from './types'; -export class RedmineRepositoryError extends Error { +export class TargetUnitRepositoryError extends Error { constructor( message: string, public originalError?: unknown, ) { super(message); - this.name = 'RedmineRepositoryError'; + this.name = 'TargetUnitRepositoryError'; if (Error.captureStackTrace) { - Error.captureStackTrace(this, RedmineRepositoryError); + Error.captureStackTrace(this, TargetUnitRepositoryError); } } } -export class RedmineRepository implements IRedmineRepository { +export class TargetUnitRepository implements ITargetUnitRepository { private readonly pool: Pool; constructor(poolProvider: IPoolProvider) { @@ -36,7 +36,7 @@ export class RedmineRepository implements IRedmineRepository { username, spent_on, total_hours, - }: ProjectUnitRow, + }: TargetUnitRow, ): ProjectUnit { return { group_id: Number(group_id), @@ -53,17 +53,17 @@ export class RedmineRepository implements IRedmineRepository { async getProjectUnits(): Promise { try { const [rows] = - await this.pool.query(PROJECT_UNITS_QUERY); + await this.pool.query(PROJECT_UNITS_QUERY); if (!Array.isArray(rows)) { - throw new RedmineRepositoryError('Query did not return an array'); + throw new TargetUnitRepositoryError('Query did not return an array'); } - return rows.map(RedmineRepository.mapRowToProjectUnit); + return rows.map(TargetUnitRepository.mapRowToProjectUnit); } catch (error) { - console.error('RedmineRepository.getProjectUnits error:', error); - throw new RedmineRepositoryError( - `RedmineRepository.getProjectUnits failed: ${(error as Error).message}`, + console.error('TargetUnitRepository.getProjectUnits error:', error); + throw new TargetUnitRepositoryError( + `TargetUnitRepository.getProjectUnits failed: ${(error as Error).message}`, error, ); } diff --git a/workers/main/src/services/redmine/RedmineService.test.ts b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts similarity index 84% rename from workers/main/src/services/redmine/RedmineService.test.ts rename to workers/main/src/services/TargetUnit/TargetUnitService.test.ts index 2d880bc..dfdba59 100644 --- a/workers/main/src/services/redmine/RedmineService.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { ProjectUnit } from '../../common/types'; -import { RedmineService } from './RedmineService'; +import { TargetUnitService } from './TargetUnitService'; const createProjectUnit = ( overrides: Partial = {}, @@ -21,13 +21,13 @@ const createMockRepo = () => ({ getProjectUnits: vi.fn(), }); -describe('RedmineService', () => { +describe('TargetUnitService', () => { let mockRepo: { getProjectUnits: Mock }; - let service: RedmineService; + let service: TargetUnitService; beforeEach(() => { mockRepo = createMockRepo(); - service = new RedmineService(mockRepo); + service = new TargetUnitService(mockRepo); }); afterEach(() => { diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.ts b/workers/main/src/services/TargetUnit/TargetUnitService.ts new file mode 100644 index 0000000..fee2a51 --- /dev/null +++ b/workers/main/src/services/TargetUnit/TargetUnitService.ts @@ -0,0 +1,10 @@ +import { ProjectUnit } from '../../common/types'; +import { ITargetUnitRepository } from './ITargetUnitRepository'; + +export class TargetUnitService { + constructor(private repo: ITargetUnitRepository) {} + + async getProjectUnits(): Promise { + return this.repo.getProjectUnits(); + } +} diff --git a/workers/main/src/services/redmine/queries.ts b/workers/main/src/services/TargetUnit/queries.ts similarity index 77% rename from workers/main/src/services/redmine/queries.ts rename to workers/main/src/services/TargetUnit/queries.ts index da9dc0d..c29f9f1 100644 --- a/workers/main/src/services/redmine/queries.ts +++ b/workers/main/src/services/TargetUnit/queries.ts @@ -23,9 +23,9 @@ FROM ( 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 >= DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 7 DAY), INTERVAL 0 DAY) AND - te.spent_on <= DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 6 - WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY) + g.type='Group' + AND te.spent_on >= DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 7) DAY) + AND te.spent_on <= DATE_ADD(CURDATE(), INTERVAL (6 - WEEKDAY(CURDATE())) 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`; diff --git a/workers/main/src/services/redmine/types.ts b/workers/main/src/services/TargetUnit/types.ts similarity index 80% rename from workers/main/src/services/redmine/types.ts rename to workers/main/src/services/TargetUnit/types.ts index 1431861..7115e7c 100644 --- a/workers/main/src/services/redmine/types.ts +++ b/workers/main/src/services/TargetUnit/types.ts @@ -1,6 +1,6 @@ import { Pool, RowDataPacket } from 'mysql2/promise'; -export interface ProjectUnitRow extends RowDataPacket { +export interface TargetUnitRow extends RowDataPacket { group_id: number; group_name: string; project_id: number; @@ -15,7 +15,7 @@ export interface IPoolProvider { getPool(): Pool; } -export type ProjectUnitsResult = { +export type TargetUnitResult = { fileLink: string; }; diff --git a/workers/main/src/services/redmine/RedmineService.ts b/workers/main/src/services/redmine/RedmineService.ts deleted file mode 100644 index 1062967..0000000 --- a/workers/main/src/services/redmine/RedmineService.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ProjectUnit } from '../../common/types'; -import { IRedmineRepository } from './IRedmineRepository'; - -export class RedmineService { - constructor(private repo: IRedmineRepository) {} - - async getProjectUnits(): Promise { - return this.repo.getProjectUnits(); - } -} From 81ca5b0340f945a2f24781fb41d4f1d3d3dde3ab Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 4 Jun 2025 19:06:00 +0200 Subject: [PATCH 08/15] Refactor ProjectUnit to TargetUnit across the codebase This change renames the `ProjectUnit` interface to `TargetUnit` and updates all related references in the repository and service layers. The modifications ensure consistency in terminology and improve clarity in the codebase. All relevant tests have been adjusted accordingly to reflect this change. --- workers/main/src/common/types.ts | 2 +- .../main/src/services/TargetUnit/ITargetUnitRepository.ts | 4 ++-- .../src/services/TargetUnit/TargetUnitRepository.test.ts | 4 ++-- .../main/src/services/TargetUnit/TargetUnitRepository.ts | 6 +++--- .../main/src/services/TargetUnit/TargetUnitService.test.ts | 6 +++--- workers/main/src/services/TargetUnit/TargetUnitService.ts | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/workers/main/src/common/types.ts b/workers/main/src/common/types.ts index 9e21c4b..2978f2b 100644 --- a/workers/main/src/common/types.ts +++ b/workers/main/src/common/types.ts @@ -1,4 +1,4 @@ -export interface ProjectUnit { +export interface TargetUnit { group_id: number; group_name: string; project_id: number; diff --git a/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts b/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts index 27e642f..b4f445f 100644 --- a/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts @@ -1,5 +1,5 @@ -import { ProjectUnit } from '../../common/types'; +import { TargetUnit } from '../../common/types'; export interface ITargetUnitRepository { - getProjectUnits(): Promise; + getProjectUnits(): Promise; } diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts index 8077f66..56a1676 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts @@ -1,7 +1,7 @@ import { Pool } from 'mysql2/promise'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { ProjectUnit } from '../../common/types'; +import { TargetUnit } from '../../common/types'; import { TargetUnitRepository, TargetUnitRepositoryError, @@ -45,7 +45,7 @@ describe('TargetUnitRepository', () => { mockPool.query.mockResolvedValueOnce([rows]); const result = await repo.getProjectUnits(); - expect(result).toEqual[]>([ + expect(result).toEqual[]>([ { group_id: 1, group_name: 'Group', diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts index 7913d4d..2fc6532 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts @@ -1,6 +1,6 @@ import { Pool } from 'mysql2/promise'; -import { ProjectUnit } from '../../common/types'; +import { TargetUnit } from '../../common/types'; import { ITargetUnitRepository } from './ITargetUnitRepository'; import { PROJECT_UNITS_QUERY } from './queries'; import { IPoolProvider, TargetUnitRow } from './types'; @@ -37,7 +37,7 @@ export class TargetUnitRepository implements ITargetUnitRepository { spent_on, total_hours, }: TargetUnitRow, - ): ProjectUnit { + ): TargetUnit { return { group_id: Number(group_id), group_name: String(group_name), @@ -50,7 +50,7 @@ export class TargetUnitRepository implements ITargetUnitRepository { }; } - async getProjectUnits(): Promise { + async getProjectUnits(): Promise { try { const [rows] = await this.pool.query(PROJECT_UNITS_QUERY); diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts index dfdba59..2ba9276 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { ProjectUnit } from '../../common/types'; +import { TargetUnit } from '../../common/types'; import { TargetUnitService } from './TargetUnitService'; const createProjectUnit = ( - overrides: Partial = {}, -): ProjectUnit => ({ + overrides: Partial = {}, +): TargetUnit => ({ group_id: 1, group_name: 'Group', project_id: 2, diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.ts b/workers/main/src/services/TargetUnit/TargetUnitService.ts index fee2a51..7a37a3d 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitService.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitService.ts @@ -1,10 +1,10 @@ -import { ProjectUnit } from '../../common/types'; +import { TargetUnit } from '../../common/types'; import { ITargetUnitRepository } from './ITargetUnitRepository'; export class TargetUnitService { constructor(private repo: ITargetUnitRepository) {} - async getProjectUnits(): Promise { + async getProjectUnits(): Promise { return this.repo.getProjectUnits(); } } From f448f6e77a58bece494878745aca3f7262b8a6b8 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 13:50:33 +0200 Subject: [PATCH 09/15] Refactor method names from getProjectUnits to getTargetUnits in TargetUnitRepository and related tests This change updates the method names in the `ITargetUnitRepository`, `TargetUnitRepository`, and `TargetUnitService` classes to reflect the new terminology. All corresponding test cases have been modified to ensure consistency and maintain functionality. This refactor enhances clarity in the codebase by aligning method names with the updated domain terminology. --- .../services/TargetUnit/ITargetUnitRepository.ts | 2 +- .../TargetUnit/TargetUnitRepository.test.ts | 6 +++--- .../services/TargetUnit/TargetUnitRepository.ts | 10 +++++----- .../TargetUnit/TargetUnitService.test.ts | 16 +++++++--------- .../src/services/TargetUnit/TargetUnitService.ts | 4 ++-- workers/main/src/services/TargetUnit/types.ts | 8 -------- 6 files changed, 18 insertions(+), 28 deletions(-) diff --git a/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts b/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts index b4f445f..114dfda 100644 --- a/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/ITargetUnitRepository.ts @@ -1,5 +1,5 @@ import { TargetUnit } from '../../common/types'; export interface ITargetUnitRepository { - getProjectUnits(): Promise; + getTargetUnits(): Promise; } diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts index 56a1676..6190e37 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts @@ -43,7 +43,7 @@ describe('TargetUnitRepository', () => { ]; mockPool.query.mockResolvedValueOnce([rows]); - const result = await repo.getProjectUnits(); + const result = await repo.getTargetUnits(); expect(result).toEqual[]>([ { @@ -61,13 +61,13 @@ describe('TargetUnitRepository', () => { it('throws TargetUnitRepositoryError if query does not return array', async () => { mockPool.query.mockResolvedValueOnce([null]); - await expect(repo.getProjectUnits()).rejects.toThrow( + await expect(repo.getTargetUnits()).rejects.toThrow( TargetUnitRepositoryError, ); }); it('throws TargetUnitRepositoryError on query error', async () => { mockPool.query.mockRejectedValueOnce(new Error('db error')); - await expect(repo.getProjectUnits()).rejects.toThrow('db error'); + await expect(repo.getTargetUnits()).rejects.toThrow('db error'); }); }); diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts index 2fc6532..683ed8b 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts @@ -25,7 +25,7 @@ export class TargetUnitRepository implements ITargetUnitRepository { this.pool = poolProvider.getPool(); } - private static mapRowToProjectUnit( + private static mapRowToTargetUnit( this: void, { group_id, @@ -50,7 +50,7 @@ export class TargetUnitRepository implements ITargetUnitRepository { }; } - async getProjectUnits(): Promise { + async getTargetUnits(): Promise { try { const [rows] = await this.pool.query(PROJECT_UNITS_QUERY); @@ -59,11 +59,11 @@ export class TargetUnitRepository implements ITargetUnitRepository { throw new TargetUnitRepositoryError('Query did not return an array'); } - return rows.map(TargetUnitRepository.mapRowToProjectUnit); + return rows.map(TargetUnitRepository.mapRowToTargetUnit); } catch (error) { - console.error('TargetUnitRepository.getProjectUnits error:', error); + console.error('TargetUnitRepository.getTargetUnits error:', error); throw new TargetUnitRepositoryError( - `TargetUnitRepository.getProjectUnits failed: ${(error as Error).message}`, + `TargetUnitRepository.getTargetUnits failed: ${(error as Error).message}`, error, ); } diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts index 2ba9276..dba975a 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts @@ -3,9 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { TargetUnit } from '../../common/types'; import { TargetUnitService } from './TargetUnitService'; -const createProjectUnit = ( - overrides: Partial = {}, -): TargetUnit => ({ +const createTargetUnit = (overrides: Partial = {}): TargetUnit => ({ group_id: 1, group_name: 'Group', project_id: 2, @@ -18,11 +16,11 @@ const createProjectUnit = ( }); const createMockRepo = () => ({ - getProjectUnits: vi.fn(), + getTargetUnits: vi.fn(), }); describe('TargetUnitService', () => { - let mockRepo: { getProjectUnits: Mock }; + let mockRepo: { getTargetUnits: Mock }; let service: TargetUnitService; beforeEach(() => { @@ -35,13 +33,13 @@ describe('TargetUnitService', () => { }); it('should return project units from the repository', async () => { - const units = [createProjectUnit()]; + const units = [createTargetUnit()]; - mockRepo.getProjectUnits.mockResolvedValueOnce(units); + mockRepo.getTargetUnits.mockResolvedValueOnce(units); - const result = await service.getProjectUnits(); + const result = await service.getTargetUnits(); expect(result).toEqual(units); - expect(mockRepo.getProjectUnits).toHaveBeenCalledTimes(1); + expect(mockRepo.getTargetUnits).toHaveBeenCalledTimes(1); }); }); diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.ts b/workers/main/src/services/TargetUnit/TargetUnitService.ts index 7a37a3d..dd09213 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitService.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitService.ts @@ -4,7 +4,7 @@ import { ITargetUnitRepository } from './ITargetUnitRepository'; export class TargetUnitService { constructor(private repo: ITargetUnitRepository) {} - async getProjectUnits(): Promise { - return this.repo.getProjectUnits(); + async getTargetUnits(): Promise { + return this.repo.getTargetUnits(); } } diff --git a/workers/main/src/services/TargetUnit/types.ts b/workers/main/src/services/TargetUnit/types.ts index 7115e7c..2896f39 100644 --- a/workers/main/src/services/TargetUnit/types.ts +++ b/workers/main/src/services/TargetUnit/types.ts @@ -14,11 +14,3 @@ export interface TargetUnitRow extends RowDataPacket { export interface IPoolProvider { getPool(): Pool; } - -export type TargetUnitResult = { - fileLink: string; -}; - -export type EmployeeRatesResult = { - fileLink: string; -}; From d7e2c90a46cc19fb52d18738a758c379a2022d6f Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 14:19:50 +0200 Subject: [PATCH 10/15] chore: Simplify TargetUnitRepository Removed IPoolProvider abstraction and updated queries and related tests for consistency and clarity. --- .../main/src/common/errors/FileUtilsError.ts | 3 +++ .../errors/TargetUnitRepositoryError.test.ts | 25 +++++++++++++++++++ .../errors/TargetUnitRepositoryError.ts | 12 +++++++++ workers/main/src/common/errors/index.ts | 1 + .../TargetUnit/TargetUnitRepository.test.ts | 16 ++++-------- .../TargetUnit/TargetUnitRepository.ts | 25 +++++-------------- .../main/src/services/TargetUnit/queries.ts | 2 +- workers/main/src/services/TargetUnit/types.ts | 6 +---- 8 files changed, 54 insertions(+), 36 deletions(-) create mode 100644 workers/main/src/common/errors/TargetUnitRepositoryError.test.ts create mode 100644 workers/main/src/common/errors/TargetUnitRepositoryError.ts diff --git a/workers/main/src/common/errors/FileUtilsError.ts b/workers/main/src/common/errors/FileUtilsError.ts index a21bfea..b2ec269 100644 --- a/workers/main/src/common/errors/FileUtilsError.ts +++ b/workers/main/src/common/errors/FileUtilsError.ts @@ -5,5 +5,8 @@ export class FileUtilsError extends Error { super(message); this.name = 'FileUtilsError'; this.cause = cause; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, FileUtilsError); + } } } diff --git a/workers/main/src/common/errors/TargetUnitRepositoryError.test.ts b/workers/main/src/common/errors/TargetUnitRepositoryError.test.ts new file mode 100644 index 0000000..06e93c3 --- /dev/null +++ b/workers/main/src/common/errors/TargetUnitRepositoryError.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { TargetUnitRepositoryError } from './TargetUnitRepositoryError'; + +describe('TargetUnitRepositoryError', () => { + it('should set the message and name', () => { + const err = new TargetUnitRepositoryError('test message'); + + expect(err.message).toBe('test message'); + expect(err.name).toBe('TargetUnitRepositoryError'); + }); + + it('should set the cause if provided', () => { + const cause = new Error('root cause'); + const err = new TargetUnitRepositoryError('with cause', cause); + + expect(err.cause).toBe(cause); + }); + + it('should not set cause if not provided', () => { + const err = new TargetUnitRepositoryError('no cause'); + + expect(err.cause).toBeUndefined(); + }); +}); diff --git a/workers/main/src/common/errors/TargetUnitRepositoryError.ts b/workers/main/src/common/errors/TargetUnitRepositoryError.ts new file mode 100644 index 0000000..0d7b607 --- /dev/null +++ b/workers/main/src/common/errors/TargetUnitRepositoryError.ts @@ -0,0 +1,12 @@ +export class TargetUnitRepositoryError extends Error { + public cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'TargetUnitRepositoryError'; + this.cause = cause; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, TargetUnitRepositoryError); + } + } +} diff --git a/workers/main/src/common/errors/index.ts b/workers/main/src/common/errors/index.ts index e223071..582c080 100644 --- a/workers/main/src/common/errors/index.ts +++ b/workers/main/src/common/errors/index.ts @@ -1 +1,2 @@ export * from './FileUtilsError'; +export * from './TargetUnitRepositoryError'; diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts index 6190e37..6645904 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.test.ts @@ -1,26 +1,20 @@ import { Pool } from 'mysql2/promise'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { TargetUnitRepositoryError } from '../../common/errors'; import { TargetUnit } from '../../common/types'; -import { - TargetUnitRepository, - TargetUnitRepositoryError, -} from './TargetUnitRepository'; -import { IPoolProvider, TargetUnitRow } from './types'; +import { TargetUnitRepository } from './TargetUnitRepository'; +import { TargetUnitRow } from './types'; describe('TargetUnitRepository', () => { let mockPool: { query: Mock }; - let mockPoolProvider: IPoolProvider; let repo: TargetUnitRepository; beforeEach(() => { mockPool = { query: vi.fn(), }; - mockPoolProvider = { - getPool: () => mockPool as unknown as Pool, - }; - repo = new TargetUnitRepository(mockPoolProvider); + repo = new TargetUnitRepository(mockPool as unknown as Pool); }); afterEach(() => { @@ -68,6 +62,6 @@ describe('TargetUnitRepository', () => { it('throws TargetUnitRepositoryError on query error', async () => { mockPool.query.mockRejectedValueOnce(new Error('db error')); - await expect(repo.getTargetUnits()).rejects.toThrow('db error'); + await expect(repo.getTargetUnits()).rejects.toThrowError('db error'); }); }); diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts index 683ed8b..5ed213d 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts @@ -1,28 +1,16 @@ import { Pool } from 'mysql2/promise'; +import { TargetUnitRepositoryError } from '../../common/errors'; import { TargetUnit } from '../../common/types'; import { ITargetUnitRepository } from './ITargetUnitRepository'; -import { PROJECT_UNITS_QUERY } from './queries'; -import { IPoolProvider, TargetUnitRow } from './types'; - -export class TargetUnitRepositoryError extends Error { - constructor( - message: string, - public originalError?: unknown, - ) { - super(message); - this.name = 'TargetUnitRepositoryError'; - if (Error.captureStackTrace) { - Error.captureStackTrace(this, TargetUnitRepositoryError); - } - } -} +import { TARGET_UNITS_QUERY } from './queries'; +import { TargetUnitRow } from './types'; export class TargetUnitRepository implements ITargetUnitRepository { private readonly pool: Pool; - constructor(poolProvider: IPoolProvider) { - this.pool = poolProvider.getPool(); + constructor(pool: Pool) { + this.pool = pool; } private static mapRowToTargetUnit( @@ -52,8 +40,7 @@ export class TargetUnitRepository implements ITargetUnitRepository { async getTargetUnits(): Promise { try { - const [rows] = - await this.pool.query(PROJECT_UNITS_QUERY); + const [rows] = await this.pool.query(TARGET_UNITS_QUERY); if (!Array.isArray(rows)) { throw new TargetUnitRepositoryError('Query did not return an array'); diff --git a/workers/main/src/services/TargetUnit/queries.ts b/workers/main/src/services/TargetUnit/queries.ts index c29f9f1..90b69fc 100644 --- a/workers/main/src/services/TargetUnit/queries.ts +++ b/workers/main/src/services/TargetUnit/queries.ts @@ -1,4 +1,4 @@ -export const PROJECT_UNITS_QUERY = `SELECT +export const TARGET_UNITS_QUERY = `SELECT group_id, group_name, project_id, diff --git a/workers/main/src/services/TargetUnit/types.ts b/workers/main/src/services/TargetUnit/types.ts index 2896f39..674ea52 100644 --- a/workers/main/src/services/TargetUnit/types.ts +++ b/workers/main/src/services/TargetUnit/types.ts @@ -1,4 +1,4 @@ -import { Pool, RowDataPacket } from 'mysql2/promise'; +import { RowDataPacket } from 'mysql2/promise'; export interface TargetUnitRow extends RowDataPacket { group_id: number; @@ -10,7 +10,3 @@ export interface TargetUnitRow extends RowDataPacket { spent_on: string; total_hours: number; } - -export interface IPoolProvider { - getPool(): Pool; -} From 1117c3698c422642b37f4745e70f5616a2671154 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 14:25:43 +0200 Subject: [PATCH 11/15] refactor: Update SQL query formatting in TargetUnit queries Revised the SQL query in `queries.ts` to improve readability by restructuring the WHERE clause and enhancing the GROUP BY and ORDER BY sections. --- .../main/src/services/TargetUnit/queries.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/workers/main/src/services/TargetUnit/queries.ts b/workers/main/src/services/TargetUnit/queries.ts index 90b69fc..7b91e22 100644 --- a/workers/main/src/services/TargetUnit/queries.ts +++ b/workers/main/src/services/TargetUnit/queries.ts @@ -22,10 +22,20 @@ FROM ( 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 >= DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 7) DAY) - AND te.spent_on <= DATE_ADD(CURDATE(), INTERVAL (6 - WEEKDAY(CURDATE())) DAY) + 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 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`; From f7231570c28e0b0fb76cedfb221d24d5cdbe9c0f Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 14:42:08 +0200 Subject: [PATCH 12/15] fix: Remove console error logging in TargetUnitRepository Eliminated the console error logging in the `getTargetUnits` method of the `TargetUnitRepository` class to streamline error handling. This change enhances the clarity of error messages thrown by the repository without cluttering the console output. --- workers/main/src/services/TargetUnit/TargetUnitRepository.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts index 5ed213d..3523869 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts @@ -48,7 +48,6 @@ export class TargetUnitRepository implements ITargetUnitRepository { return rows.map(TargetUnitRepository.mapRowToTargetUnit); } catch (error) { - console.error('TargetUnitRepository.getTargetUnits error:', error); throw new TargetUnitRepositoryError( `TargetUnitRepository.getTargetUnits failed: ${(error as Error).message}`, error, From b698473625e8b2f684d4dd073ba7a52be369d140 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 15:18:55 +0200 Subject: [PATCH 13/15] refactor: Simplify mapRowToTargetUnit method in TargetUnitRepository Refactored the `mapRowToTargetUnit` method in the `TargetUnitRepository` class to use an arrow function for improved readability and conciseness. This change enhances the clarity of the code while maintaining the same functionality. --- .../TargetUnit/TargetUnitRepository.ts | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts index 3523869..6a4edbc 100644 --- a/workers/main/src/services/TargetUnit/TargetUnitRepository.ts +++ b/workers/main/src/services/TargetUnit/TargetUnitRepository.ts @@ -13,30 +13,25 @@ export class TargetUnitRepository implements ITargetUnitRepository { this.pool = pool; } - private static mapRowToTargetUnit( - this: void, - { - group_id, - group_name, - project_id, - project_name, - user_id, - username, - spent_on, - total_hours, - }: TargetUnitRow, - ): TargetUnit { - return { - group_id: Number(group_id), - group_name: String(group_name), - project_id: Number(project_id), - project_name: String(project_name), - user_id: Number(user_id), - username: String(username), - spent_on: String(spent_on), - total_hours: Number(total_hours), - }; - } + private static mapRowToTargetUnit = ({ + group_id, + group_name, + project_id, + project_name, + user_id, + username, + spent_on, + total_hours, + }: TargetUnitRow): TargetUnit => ({ + group_id: Number(group_id), + group_name: String(group_name), + project_id: Number(project_id), + project_name: String(project_name), + user_id: Number(user_id), + username: String(username), + spent_on: String(spent_on), + total_hours: Number(total_hours), + }); async getTargetUnits(): Promise { try { From 88c6809da6723a5af9b9912e5a23f491c33531cb Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 15:22:29 +0200 Subject: [PATCH 14/15] refactor: Remove TargetUnitService and its tests Deleted the `TargetUnitService` class and its associated test file to streamline the codebase. This change eliminates unnecessary complexity and focuses on more relevant components, enhancing overall maintainability. --- .../TargetUnit/TargetUnitService.test.ts | 45 ------------------- .../services/TargetUnit/TargetUnitService.ts | 10 ----- 2 files changed, 55 deletions(-) delete mode 100644 workers/main/src/services/TargetUnit/TargetUnitService.test.ts delete mode 100644 workers/main/src/services/TargetUnit/TargetUnitService.ts diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts deleted file mode 100644 index dba975a..0000000 --- a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; - -import { TargetUnit } from '../../common/types'; -import { TargetUnitService } from './TargetUnitService'; - -const createTargetUnit = (overrides: Partial = {}): TargetUnit => ({ - group_id: 1, - group_name: 'Group', - project_id: 2, - project_name: 'Project', - user_id: 3, - username: 'User', - spent_on: '2024-06-01', - total_hours: 8, - ...overrides, -}); - -const createMockRepo = () => ({ - getTargetUnits: vi.fn(), -}); - -describe('TargetUnitService', () => { - let mockRepo: { getTargetUnits: Mock }; - let service: TargetUnitService; - - beforeEach(() => { - mockRepo = createMockRepo(); - service = new TargetUnitService(mockRepo); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should return project units from the repository', async () => { - const units = [createTargetUnit()]; - - mockRepo.getTargetUnits.mockResolvedValueOnce(units); - - const result = await service.getTargetUnits(); - - expect(result).toEqual(units); - expect(mockRepo.getTargetUnits).toHaveBeenCalledTimes(1); - }); -}); diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.ts b/workers/main/src/services/TargetUnit/TargetUnitService.ts deleted file mode 100644 index dd09213..0000000 --- a/workers/main/src/services/TargetUnit/TargetUnitService.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TargetUnit } from '../../common/types'; -import { ITargetUnitRepository } from './ITargetUnitRepository'; - -export class TargetUnitService { - constructor(private repo: ITargetUnitRepository) {} - - async getTargetUnits(): Promise { - return this.repo.getTargetUnits(); - } -} From 35368da1f5e7f96ed5fe9e7c77d3f56787c6d8b9 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 5 Jun 2025 15:29:14 +0200 Subject: [PATCH 15/15] feat: Implement TargetUnitService and its tests Added the `TargetUnitService` class along with its corresponding test file to manage target unit retrieval from the repository. This implementation enhances the functionality of the service layer by providing a dedicated method for fetching target units, improving code organization and maintainability. --- .../TargetUnit/TargetUnitService.test.ts | 45 +++++++++++++++++++ .../services/TargetUnit/TargetUnitService.ts | 10 +++++ 2 files changed, 55 insertions(+) create mode 100644 workers/main/src/services/TargetUnit/TargetUnitService.test.ts create mode 100644 workers/main/src/services/TargetUnit/TargetUnitService.ts diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.test.ts b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts new file mode 100644 index 0000000..dba975a --- /dev/null +++ b/workers/main/src/services/TargetUnit/TargetUnitService.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { TargetUnit } from '../../common/types'; +import { TargetUnitService } from './TargetUnitService'; + +const createTargetUnit = (overrides: Partial = {}): TargetUnit => ({ + group_id: 1, + group_name: 'Group', + project_id: 2, + project_name: 'Project', + user_id: 3, + username: 'User', + spent_on: '2024-06-01', + total_hours: 8, + ...overrides, +}); + +const createMockRepo = () => ({ + getTargetUnits: vi.fn(), +}); + +describe('TargetUnitService', () => { + let mockRepo: { getTargetUnits: Mock }; + let service: TargetUnitService; + + beforeEach(() => { + mockRepo = createMockRepo(); + service = new TargetUnitService(mockRepo); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return project units from the repository', async () => { + const units = [createTargetUnit()]; + + mockRepo.getTargetUnits.mockResolvedValueOnce(units); + + const result = await service.getTargetUnits(); + + expect(result).toEqual(units); + expect(mockRepo.getTargetUnits).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workers/main/src/services/TargetUnit/TargetUnitService.ts b/workers/main/src/services/TargetUnit/TargetUnitService.ts new file mode 100644 index 0000000..dd09213 --- /dev/null +++ b/workers/main/src/services/TargetUnit/TargetUnitService.ts @@ -0,0 +1,10 @@ +import { TargetUnit } from '../../common/types'; +import { ITargetUnitRepository } from './ITargetUnitRepository'; + +export class TargetUnitService { + constructor(private repo: ITargetUnitRepository) {} + + async getTargetUnits(): Promise { + return this.repo.getTargetUnits(); + } +}