From 22c64725261d6ed37862346b46c6d8afa9e955bf Mon Sep 17 00:00:00 2001 From: yqni13 Date: Mon, 9 Mar 2026 15:10:59 +0100 Subject: [PATCH 1/7] refactor: nominal type clients/users id --- backend/src/controllers/clients.controller.ts | 3 +- backend/src/controllers/users.controller.ts | 5 +- backend/src/dtos/clients.dto.ts | 11 +-- backend/src/dtos/feedback-rating.dto.ts | 6 +- backend/src/dtos/feedback.dto.ts | 15 ++-- backend/src/dtos/rate-limits.dto.ts | 19 +++-- backend/src/dtos/tickets.dto.ts | 22 ++--- backend/src/dtos/users.dto.ts | 3 +- .../rate-limits.interface.middleware.ts | 8 +- backend/src/models/clients.model.ts | 4 +- backend/src/models/users.model.ts | 4 +- .../src/repositories/clients.repository.ts | 10 +-- .../interfaces/clients.entity.interface.ts | 4 +- .../feedback-rating.entity.interface.ts | 4 +- .../interfaces/feedback.entity.interface.ts | 7 +- .../rate-limits.entity.interface.ts | 7 +- .../interfaces/tickets.entity.interface.ts | 6 +- .../interfaces/users.entity.interface.ts | 4 +- backend/src/repositories/users.repository.ts | 8 +- backend/src/services/clients.service.ts | 10 +-- backend/src/services/users.service.ts | 8 +- backend/tests/common.test-utils.ts | 3 +- .../repositories/clients.integration.test.ts | 18 ++-- .../feedback-rating.integration.test.ts | 6 +- .../repositories/feedback.integration.test.ts | 62 +++++++------- .../rate-limits.integration.test.ts | 28 ++++--- .../repositories/tickets.integration.test.ts | 82 ++++++++++--------- .../repositories/users.integration.test.ts | 20 +++-- .../middleware/auth.client.middleware.test.ts | 5 +- .../middleware/auth.user.middleware.test.ts | 11 +-- .../middleware/engines.middleware.test.ts | 19 +++-- .../rate-limits.rules.middleware.test.ts | 35 ++++---- .../unit-tests/models/clients.models.test.ts | 7 +- .../models/feedback-rating.model.test.ts | 16 ++-- .../unit-tests/models/feedback.model.test.ts | 12 ++- .../models/rate-limits.models.test.ts | 21 +++-- .../unit-tests/models/tickets.model.test.ts | 28 ++++--- .../unit-tests/models/users.models.test.ts | 5 +- .../repositories/clients.repositories.test.ts | 36 ++++---- .../feedback-rating.repositories.test.ts | 8 +- .../feedback.repositories.test.ts | 10 ++- .../rate-limits.repositories.test.ts | 10 ++- .../repositories/tickets.repositories.test.ts | 46 ++++++----- .../repositories/users.repositories.test.ts | 27 +++--- .../unit-tests/utils/common.utils.test.ts | 16 ++-- 45 files changed, 391 insertions(+), 308 deletions(-) diff --git a/backend/src/controllers/clients.controller.ts b/backend/src/controllers/clients.controller.ts index 11dd7c9..17be71e 100644 --- a/backend/src/controllers/clients.controller.ts +++ b/backend/src/controllers/clients.controller.ts @@ -7,6 +7,7 @@ import { ClientsStatusUpdateDTO } from "../dtos/clients.dto"; import clientsService from "../services/clients.service"; +import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; class ClientsController { async getClientStatus(req: Request, res: Response, next: NextFunction) { @@ -34,7 +35,7 @@ class ClientsController { async patchClientStatus(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id: string = req.params.id; + const id = req.params.id as ClientsId; const dto: ClientsStatusUpdateDTO = req.body; const response: ClientsStatusResponseDTO | null = await clientsService.updateClientStatus(id, dto); res.json(response); diff --git a/backend/src/controllers/users.controller.ts b/backend/src/controllers/users.controller.ts index 23d9dde..9902242 100644 --- a/backend/src/controllers/users.controller.ts +++ b/backend/src/controllers/users.controller.ts @@ -2,12 +2,13 @@ import { NextFunction, Request, Response } from "express"; import { UsersUpdateDTO, UsersFilterDTO, UsersResponseDTO, UsersCreateDTO } from "../dtos/users.dto"; import { checkValidation } from "../middleware/validation.middleware"; import usersService from "../services/users.service"; +import { UsersId } from "../repositories/interfaces/users.entity.interface"; class UsersController { async getUserById(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id = req.params.id; + const id = req.params.id as UsersId; const response: UsersResponseDTO | null = await usersService.getUserById(id); res.json(response); } catch(err: any) { @@ -65,7 +66,7 @@ class UsersController { async patchUser(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id: string = req.params.id; + const id = req.params.id as UsersId; const dto: UsersUpdateDTO = req.body; const response: UsersResponseDTO | null = await usersService.updateUser(id, dto); res.json(response); diff --git a/backend/src/dtos/clients.dto.ts b/backend/src/dtos/clients.dto.ts index e72ce5c..ab95c0a 100644 --- a/backend/src/dtos/clients.dto.ts +++ b/backend/src/dtos/clients.dto.ts @@ -1,3 +1,4 @@ +import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; import { ApiKeyStatus } from "../utils/enums/api-key-status.enum"; import { Flag } from "../utils/enums/flag.enum"; @@ -20,7 +21,7 @@ export interface ClientsLastUseUpdateDTO { } export interface ClientsExistResponseDTO { - client_id: string, + client_id: ClientsId, name: string, api_key_hash: string, status: ApiKeyStatus, @@ -31,7 +32,7 @@ export interface ClientsExistResponseDTO { } export interface ClientsCreateResponseDTO { - client_id: string, + client_id: ClientsId, name: string, api_key: string, status: ApiKeyStatus, @@ -42,7 +43,7 @@ export interface ClientsCreateResponseDTO { } export interface ClientsFlagResponseDTO { - client_id: string, + client_id: ClientsId, flag: Flag | null, last_use: string, last_modified: string, @@ -50,7 +51,7 @@ export interface ClientsFlagResponseDTO { } export interface ClientsStatusResponseDTO { - client_id: string, + client_id: ClientsId, name: string, status: ApiKeyStatus, last_use: string, @@ -59,7 +60,7 @@ export interface ClientsStatusResponseDTO { } export interface ClientsLastUseResponseDTO { - client_id: string, + client_id: ClientsId, name: string, last_use: string, last_modified: string, diff --git a/backend/src/dtos/feedback-rating.dto.ts b/backend/src/dtos/feedback-rating.dto.ts index 4408a4a..b9e638b 100644 --- a/backend/src/dtos/feedback-rating.dto.ts +++ b/backend/src/dtos/feedback-rating.dto.ts @@ -1,5 +1,7 @@ +import { ClientsId } from "../repositories/interfaces/clients.entity.interface" + export interface FeedbackRatingCreateDTO { - client_id: string, + client_id: ClientsId, count?: number, rating_sum?: number } @@ -15,7 +17,7 @@ export interface FeedbackRatingResponseDTO { } export interface FeedbackRatingExtendedResponseDTO extends FeedbackRatingResponseDTO { - client_id: string, + client_id: ClientsId, count: number, rating_sum: number, last_modified: string, diff --git a/backend/src/dtos/feedback.dto.ts b/backend/src/dtos/feedback.dto.ts index 73e26eb..88c0618 100644 --- a/backend/src/dtos/feedback.dto.ts +++ b/backend/src/dtos/feedback.dto.ts @@ -1,6 +1,9 @@ +import { ClientsId } from "../repositories/interfaces/clients.entity.interface" +import { UsersId } from "../repositories/interfaces/users.entity.interface" + export interface FeedbackCreateDTO { - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, rating: number, term_accepted: boolean, message?: string, @@ -19,8 +22,8 @@ export interface FeedbackUpdateReviewDTO { } export interface FeedbackFilterDTO { - client_id?: string | string[], - user_id?: string | string[], + client_id?: ClientsId | ClientsId[], + user_id?: UsersId | UsersId[], rating?: number | number[], term_accepted?: boolean, reviewed_on?: string | string[], @@ -30,8 +33,8 @@ export interface FeedbackFilterDTO { export interface FeedbackResponseDTO { feedback_id: number, - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, rating: number, rating_average_new?: number, rating_old?: number, diff --git a/backend/src/dtos/rate-limits.dto.ts b/backend/src/dtos/rate-limits.dto.ts index bf6fb39..e673d23 100644 --- a/backend/src/dtos/rate-limits.dto.ts +++ b/backend/src/dtos/rate-limits.dto.ts @@ -1,25 +1,28 @@ +import { ClientsId } from "../repositories/interfaces/clients.entity.interface" +import { UsersId } from "../repositories/interfaces/users.entity.interface" + export interface RateLimitsCountDTO { - client_id?: string, - user_id?: string, + client_id?: ClientsId, + user_id?: UsersId, day: string } export interface RateLimitsCreateDTO { - client_id: string, - user_id: string + client_id: ClientsId, + user_id: UsersId } export interface RateLimitsUpdateDTO { - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, day?: string, last_modified?: string } export interface RateLimitsResponseDTO { rate_limit_id: number, - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, day: string, count: number, last_modified: string diff --git a/backend/src/dtos/tickets.dto.ts b/backend/src/dtos/tickets.dto.ts index 79fd8db..d7d900a 100644 --- a/backend/src/dtos/tickets.dto.ts +++ b/backend/src/dtos/tickets.dto.ts @@ -1,3 +1,5 @@ +import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../repositories/interfaces/users.entity.interface"; import { SingleOrArray } from "../utils/custom-types.utils"; import { DeviceOption } from "../utils/enums/device-option.enum"; import { Flag } from "../utils/enums/flag.enum"; @@ -5,14 +7,14 @@ import { TicketOption } from "../utils/enums/ticket-option.enum"; import { TicketStatus } from "../utils/enums/ticket-status.enum"; export interface TicketsIntervalDTO { - client_id?: string, - user_id?: string, + client_id?: ClientsId, + user_id?: UsersId, intervalTime: string } export interface TicketsCreateDTO { - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, option: TicketOption, title: string, message: string, @@ -49,8 +51,8 @@ export interface TicketsUpdateDTO { } export interface TicketsFilterDTO { - client_id?: SingleOrArray, - user_id?: SingleOrArray, + client_id?: SingleOrArray, + user_id?: SingleOrArray, title?: string, status?: SingleOrArray, option?: SingleOrArray, @@ -61,8 +63,8 @@ export interface TicketsFilterDTO { export interface TicketsResponseDTO { ticket_id: string, - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, status: TicketStatus, option: TicketOption, title: string, @@ -78,9 +80,9 @@ export interface TicketsResponseDTO { export interface TicketsResponseExtendedDTO { ticket_id: string, - client_id: string, + client_id: ClientsId, client_name: string, - user_id: string, + user_id: UsersId, user_email: string, status: TicketStatus, option: TicketOption, diff --git a/backend/src/dtos/users.dto.ts b/backend/src/dtos/users.dto.ts index 0c14b92..3d43ae4 100644 --- a/backend/src/dtos/users.dto.ts +++ b/backend/src/dtos/users.dto.ts @@ -1,6 +1,7 @@ import { UserStatus } from "../utils/enums/user-status.enum"; import { Flag } from "../utils/enums/flag.enum"; import { SingleOrArray } from "../utils/custom-types.utils"; +import { UsersId } from "../repositories/interfaces/users.entity.interface"; export interface UsersCreateDTO { email: string, @@ -27,7 +28,7 @@ export interface UsersFilterDTO { } export interface UsersResponseDTO { - user_id: string, + user_id: UsersId, email: string, status: UserStatus, flag: Flag | null, diff --git a/backend/src/middleware/interfaces/rate-limits.interface.middleware.ts b/backend/src/middleware/interfaces/rate-limits.interface.middleware.ts index 704a202..11caa32 100644 --- a/backend/src/middleware/interfaces/rate-limits.interface.middleware.ts +++ b/backend/src/middleware/interfaces/rate-limits.interface.middleware.ts @@ -1,10 +1,10 @@ -import { Clients } from "../../repositories/interfaces/clients.entity.interface"; -import { Users } from "../../repositories/interfaces/users.entity.interface"; +import { Clients, ClientsId } from "../../repositories/interfaces/clients.entity.interface"; +import { Users, UsersId } from "../../repositories/interfaces/users.entity.interface"; import { PenaltyContext } from "./penalties.interface.middleware"; export interface RateLimitsData { - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, client?: Clients, user?: Users, } diff --git a/backend/src/models/clients.model.ts b/backend/src/models/clients.model.ts index ecf980a..73e03c1 100644 --- a/backend/src/models/clients.model.ts +++ b/backend/src/models/clients.model.ts @@ -2,7 +2,7 @@ import { ClientsCreateDTO, ClientsCreateResponseDTO, } from "../dtos/clients.dto"; -import { Clients } from "../repositories/interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "../repositories/interfaces/clients.entity.interface"; import crypto from 'crypto'; import * as CommonUtils from "../utils/common.utils"; import { ApiKeyStatus } from "../utils/enums/api-key-status.enum"; @@ -43,7 +43,7 @@ class ClientsModel { } generateClientsCreateObj(dto: ClientsCreateDTO): { client: Clients, keyRaw: string } { - const id = CommonUtils.generateUUID(); + const id = CommonUtils.generateUUID() as ClientsId; const keyObj = this._generateApiKeyObj(); const timestamp = CommonUtils.getTimestampUTC(); const client: Clients = { diff --git a/backend/src/models/users.model.ts b/backend/src/models/users.model.ts index ab34214..b0d7b7f 100644 --- a/backend/src/models/users.model.ts +++ b/backend/src/models/users.model.ts @@ -1,5 +1,5 @@ import { UsersCreateDTO } from "../dtos/users.dto"; -import { Users } from "../repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../utils/common.utils"; import { UserStatus } from "../utils/enums/user-status.enum"; @@ -7,7 +7,7 @@ class UsersModel { generateUser(dto: UsersCreateDTO): Users { const timestamp = CommonUtils.getTimestampUTC(); return { - user_id: CommonUtils.generateUUID(), + user_id: CommonUtils.generateUUID() as UsersId, email: dto.email, status: UserStatus.ACTIVE, flag: null, diff --git a/backend/src/repositories/clients.repository.ts b/backend/src/repositories/clients.repository.ts index 26adc3e..6e85de4 100644 --- a/backend/src/repositories/clients.repository.ts +++ b/backend/src/repositories/clients.repository.ts @@ -1,4 +1,4 @@ -import { Clients } from "./interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "./interfaces/clients.entity.interface"; import { DBConnection } from "../configs/db"; import { QueryResult } from "pg"; import { ApiKeyStatus } from "../utils/enums/api-key-status.enum"; @@ -13,7 +13,7 @@ class ClientsRepository implements ICreateRepository, IUpdateFlagReposi this.table = "clients"; } - async findById(id: string): Promise { + async findById(id: ClientsId): Promise { const filterColumn = 'client_id'; const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; const value = [id]; @@ -94,7 +94,7 @@ class ClientsRepository implements ICreateRepository, IUpdateFlagReposi } } - async updateFlag(id: string, dto: Partial): Promise { + async updateFlag(id: ClientsId, dto: Partial): Promise { const filterColumn = "client_id"; const sql = `UPDATE ${this.table} SET flag = $1, last_modified = $2 @@ -118,7 +118,7 @@ class ClientsRepository implements ICreateRepository, IUpdateFlagReposi } } - async updateStatus(id: string, dto: Partial): Promise { + async updateStatus(id: ClientsId, dto: Partial): Promise { const filterColumn = "client_id"; const sql = `UPDATE ${this.table} SET status = $1, last_modified = $2 @@ -142,7 +142,7 @@ class ClientsRepository implements ICreateRepository, IUpdateFlagReposi } } - async updateLastUse(id: string, dto: Partial): Promise { + async updateLastUse(id: ClientsId, dto: Partial): Promise { const filterColumn = "client_id"; const sql = `UPDATE ${this.table} SET last_use = $1 diff --git a/backend/src/repositories/interfaces/clients.entity.interface.ts b/backend/src/repositories/interfaces/clients.entity.interface.ts index c2d1cd3..67c3d76 100644 --- a/backend/src/repositories/interfaces/clients.entity.interface.ts +++ b/backend/src/repositories/interfaces/clients.entity.interface.ts @@ -1,8 +1,10 @@ import { ApiKeyStatus } from "../../utils/enums/api-key-status.enum"; import { Flag } from "../../utils/enums/flag.enum"; +export type ClientsId = string & { readonly brand: unique symbol }; + export interface Clients { - client_id: string, + client_id: ClientsId, name: string, api_key_hash: string, status: ApiKeyStatus, diff --git a/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts b/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts index 0cffb89..aeee659 100644 --- a/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts +++ b/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts @@ -1,6 +1,8 @@ +import { ClientsId } from "./clients.entity.interface"; + // Use Clients FK as PK => 1:1 relationship as client is unique for each feedback-rating. export interface FeedbackRating { - client_id: string, + client_id: ClientsId, count: number, rating_sum: number, last_modified: string, diff --git a/backend/src/repositories/interfaces/feedback.entity.interface.ts b/backend/src/repositories/interfaces/feedback.entity.interface.ts index 33fe571..16b63c3 100644 --- a/backend/src/repositories/interfaces/feedback.entity.interface.ts +++ b/backend/src/repositories/interfaces/feedback.entity.interface.ts @@ -1,7 +1,10 @@ +import { ClientsId } from "./clients.entity.interface"; +import { UsersId } from "./users.entity.interface"; + export interface Feedback { feedback_id: number, - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, rating: number, term_accepted: boolean, message?: string, diff --git a/backend/src/repositories/interfaces/rate-limits.entity.interface.ts b/backend/src/repositories/interfaces/rate-limits.entity.interface.ts index 66d2221..0976b24 100644 --- a/backend/src/repositories/interfaces/rate-limits.entity.interface.ts +++ b/backend/src/repositories/interfaces/rate-limits.entity.interface.ts @@ -1,7 +1,10 @@ +import { ClientsId } from "./clients.entity.interface"; +import { UsersId } from "./users.entity.interface"; + export interface RateLimits { rate_limit_id: number, - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, day: string, count: number, last_modified: string diff --git a/backend/src/repositories/interfaces/tickets.entity.interface.ts b/backend/src/repositories/interfaces/tickets.entity.interface.ts index 20c31c9..99b8c71 100644 --- a/backend/src/repositories/interfaces/tickets.entity.interface.ts +++ b/backend/src/repositories/interfaces/tickets.entity.interface.ts @@ -2,11 +2,13 @@ import { DeviceOption } from "../../utils/enums/device-option.enum"; import { Flag } from "../../utils/enums/flag.enum"; import { TicketOption } from "../../utils/enums/ticket-option.enum"; import { TicketStatus } from "../../utils/enums/ticket-status.enum"; +import { ClientsId } from "./clients.entity.interface"; +import { UsersId } from "./users.entity.interface"; export interface Tickets { ticket_id: string, - client_id: string, - user_id: string, + client_id: ClientsId, + user_id: UsersId, status: TicketStatus, option: TicketOption, title: string, diff --git a/backend/src/repositories/interfaces/users.entity.interface.ts b/backend/src/repositories/interfaces/users.entity.interface.ts index c387478..80384f3 100644 --- a/backend/src/repositories/interfaces/users.entity.interface.ts +++ b/backend/src/repositories/interfaces/users.entity.interface.ts @@ -1,8 +1,10 @@ import { Flag } from "../../utils/enums/flag.enum"; import { UserStatus } from "../../utils/enums/user-status.enum"; +export type UsersId = string & { readonly brand: unique symbol }; + export interface Users { - user_id: string, + user_id: UsersId, email: string, status: UserStatus, flag: Flag | null, diff --git a/backend/src/repositories/users.repository.ts b/backend/src/repositories/users.repository.ts index 063a3cd..d579c99 100644 --- a/backend/src/repositories/users.repository.ts +++ b/backend/src/repositories/users.repository.ts @@ -1,7 +1,7 @@ import { DBConnection } from "../configs/db"; import { QueryResult } from "pg"; import { IBaseRepository, ICreateRepository, IFindRepository, IUpdateFlagRepository } from "./interfaces/base.repository.interface"; -import { Users } from "./interfaces/users.entity.interface"; +import { Users, UsersId } from "./interfaces/users.entity.interface"; import { logError } from "../utils/common.utils"; import { UsersFilterDTO } from "../dtos/users.dto"; import { DBQueryErrorException } from "../utils/exceptions/db.exception"; @@ -19,7 +19,7 @@ IUpdateFlagRepository this.table = "users"; } - async findById(id: string): Promise { + async findById(id: UsersId): Promise { const filterColumn = "user_id"; const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; const value = [id]; @@ -122,7 +122,7 @@ IUpdateFlagRepository } } - async update(id: string, dto: Partial): Promise { + async update(id: UsersId, dto: Partial): Promise { const filterColumn = "user_id"; const sql = `UPDATE ${this.table} SET email = $1, status = $2, flag = $3, last_modified = $4 @@ -146,7 +146,7 @@ IUpdateFlagRepository } } - async updateFlag(id: string, dto: Partial): Promise { + async updateFlag(id: UsersId, dto: Partial): Promise { const filterColumn = "user_id"; const sql = `UPDATE ${this.table} SET flag = $1, last_modified = $2 diff --git a/backend/src/services/clients.service.ts b/backend/src/services/clients.service.ts index 4876f8c..6fc9a16 100644 --- a/backend/src/services/clients.service.ts +++ b/backend/src/services/clients.service.ts @@ -12,7 +12,7 @@ import { import clientsModel from '../models/clients.model'; import * as CommonUtils from '../utils/common.utils'; import clientsRepository from '../repositories/clients.repository'; -import { Clients } from "../repositories/interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "../repositories/interfaces/clients.entity.interface"; class ClientsService { private timeMapTargets: string[]; @@ -24,7 +24,7 @@ class ClientsService { /** * @description Usage for testing purpose. */ - async getClientById(id: string): Promise { + async getClientById(id: ClientsId): Promise { const result = await clientsRepository.findById(id); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } @@ -49,19 +49,19 @@ class ClientsService { return clientsModel.mapToCreateResponseDTO(result as Clients, clientsCreateObj.keyRaw); } - async updateClientFlag(id: string, dto: ClientsFlagUpdateDTO): Promise { + async updateClientFlag(id: ClientsId, dto: ClientsFlagUpdateDTO): Promise { dto.last_modified = CommonUtils.getTimestampUTC(); const result = await clientsRepository.updateFlag(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async updateClientStatus(id: string, dto: ClientsStatusUpdateDTO): Promise { + async updateClientStatus(id: ClientsId, dto: ClientsStatusUpdateDTO): Promise { dto.last_modified = CommonUtils.getTimestampUTC(); const result = await clientsRepository.updateStatus(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async updateClientLastUse(id: string): Promise { + async updateClientLastUse(id: ClientsId): Promise { const dto: ClientsLastUseUpdateDTO = { last_use: CommonUtils.getTimestampUTC() }; const result = await clientsRepository.updateLastUse(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); diff --git a/backend/src/services/users.service.ts b/backend/src/services/users.service.ts index 372d6e2..dbd90af 100644 --- a/backend/src/services/users.service.ts +++ b/backend/src/services/users.service.ts @@ -8,7 +8,7 @@ import { import usersRepository from "../repositories/users.repository"; import * as CommonUtils from "../utils/common.utils"; import usersModel from "../models/users.model"; -import { Users } from "../repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../repositories/interfaces/users.entity.interface"; class UsersService { private timeMapTargets: string[]; @@ -17,7 +17,7 @@ class UsersService { this.timeMapTargets = ['last_modified', 'created_on']; } - async getUserById(id: string): Promise { + async getUserById(id: UsersId): Promise { const result = await usersRepository.findById(id); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } @@ -43,13 +43,13 @@ class UsersService { return CommonUtils.mapObjTimestamps(result, this.timeMapTargets) } - async updateUser(id: string, dto: UsersUpdateDTO): Promise { + async updateUser(id: UsersId, dto: UsersUpdateDTO): Promise { dto.last_modified = CommonUtils.getTimestampUTC(); const result = await usersRepository.update(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async updateUserFlag(id: string, dto: UsersFlagUpdateDTO): Promise { + async updateUserFlag(id: UsersId, dto: UsersFlagUpdateDTO): Promise { dto.last_modified = CommonUtils.getTimestampUTC(); const result = await usersRepository.updateFlag(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); diff --git a/backend/tests/common.test-utils.ts b/backend/tests/common.test-utils.ts index d50c945..51ccc87 100644 --- a/backend/tests/common.test-utils.ts +++ b/backend/tests/common.test-utils.ts @@ -1,6 +1,7 @@ import { QueryResult } from "pg"; import { DBConnection } from "../src/configs/db"; import { NextFunction, Request, Response } from "express"; +import { UsersId } from "../src/repositories/interfaces/users.entity.interface"; type MockClient = { query: jest.Mock @@ -50,7 +51,7 @@ export function injectTestClientId(clientId: string) { /** * @description Used in combination with createTestApp() to mock user authentication for flexible testing. */ -export function injectTestUserId(userId: string) { +export function injectTestUserId(userId: UsersId) { return function (req: Request, res: Response, next: NextFunction) { (req as any).apiUsers = { user_id: userId }; next(); diff --git a/backend/tests/integration-tests/repositories/clients.integration.test.ts b/backend/tests/integration-tests/repositories/clients.integration.test.ts index a868924..4ac6294 100644 --- a/backend/tests/integration-tests/repositories/clients.integration.test.ts +++ b/backend/tests/integration-tests/repositories/clients.integration.test.ts @@ -16,7 +16,7 @@ import { ApiKeyStatus } from "../../../src/utils/enums/api-key-status.enum"; import { DBTestSetup } from "../../db-container.setup"; import { runMigrations } from '../../db-migrations.setup'; import clientsModel from "../../../src/models/clients.model"; -import { Clients } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { CommonExceptionMessage } from "../../../src/utils/enums/common-exception-messages.enum"; import { default as mockId } from "../../mock-data/id.mock-data.json"; import { secrets } from "../../../src/utils/secrets.utils"; @@ -33,6 +33,8 @@ jest.mock('../../../src/middleware/maintenance.middleware', () => ({ import app from '../../../src/app'; jest.setTimeout(60000); + +const mockValidClientId = mockId.clients.valid[0] as ClientsId; const testTimestamp = '2025-01-01T14:00:02.000Z'; describe('Integration-tests (repository), priority: entity Clients', () => { @@ -59,9 +61,9 @@ describe('Integration-tests (repository), priority: entity Clients', () => { describe('Testing valid fn calls', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = mockId.clients.valid[0]; + const testParam_id = mockValidClientId; const testResult: ClientsExistResponseDTO | null = { - client_id: mockId.clients.valid[0], + client_id: testParam_id, name: 'TESTCLIENT', api_key_hash: secrets.TEST_APIKEY_HASH, status: ApiKeyStatus.ACTIVE, @@ -80,7 +82,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { test('Repository process fn findStatusByName(), result: "SUCCESS"', async () => { const testParam_name = 'TESTCLIENT'; const testResult: ClientsStatusResponseDTO = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, name: testParam_name, status: ApiKeyStatus.ACTIVE, last_use: testTimestamp, @@ -109,7 +111,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { }) test('Repository process fn create(), result: "SUCCESS"', async () => { - const testParam_client_id = mockId.clients.new[0]; + const testParam_client_id = mockId.clients.new[0] as ClientsId; const testParam_dto = { name: 'testclient_test_create' }; jest.spyOn(CommonUtils, 'generateUUID').mockReturnValue(testParam_client_id); @@ -137,7 +139,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { }) test('Repository process fn updateFlag(), result: "SUCCESS"', async () => { - const testParam_id = mockId.clients.valid[0]; + const testParam_id = mockValidClientId; const testParam_dto: ClientsFlagUpdateDTO = { flag: Flag.WARNING }; @@ -159,7 +161,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { }) test('Repository process fn updateFlag(), result: null', async () => { - const testParam_id = mockId.clients.invalid[0]; + const testParam_id = mockId.clients.invalid[0] as ClientsId; const testParam_dto: ClientsFlagUpdateDTO = { flag: Flag.WARNING }; @@ -175,7 +177,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { }) test('Repository process fn updateStatus(), result: "SUCCESS"', async () => { - const testParam_id = mockId.clients.valid[0]; + const testParam_id = mockValidClientId; const testParam_data: Partial = { status: ApiKeyStatus.DISABLED }; diff --git a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts index 56692be..8ef2288 100644 --- a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts @@ -22,9 +22,11 @@ jest.mock('../../../src/middleware/maintenance.middleware', () => ({ import app from '../../../src/app'; import { DBTestData } from "../../db-data.setup"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.setTimeout(60000); +const mockValidClientId = mockId.clients.valid[0] as ClientsId; const testTimestamp = '2025-01-01T14:00:09.000Z'; describe('Integration-tests (repository), priority: entity FeedbackRating', () => { @@ -53,7 +55,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = describe('Testing valid fn calls', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = mockId.clients.valid[0]; + const testParam_id = mockValidClientId; const testResult: FeedbackRatingExtendedResponseDTO | null = { client_id: testParam_id, count: dbData_FeedbackRating[1], @@ -89,7 +91,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = test('Repository process fn findAll(), result: "SUCCESS"', async () => { const testResult: FeedbackRatingExtendedResponseDTO[] | null = [ { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: dbData_FeedbackRating[1], rating_sum: dbData_FeedbackRating[2], rating_average: 4.2, diff --git a/backend/tests/integration-tests/repositories/feedback.integration.test.ts b/backend/tests/integration-tests/repositories/feedback.integration.test.ts index dee45d9..7167798 100644 --- a/backend/tests/integration-tests/repositories/feedback.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback.integration.test.ts @@ -16,9 +16,11 @@ import feedbackRepository from '../../../src/repositories/feedback.repository'; import feedbackService from '../../../src/services/feedback.service'; import { DBQueryErrorException } from '../../../src/utils/exceptions/db.exception'; import feedbackRatingRepository from '../../../src/repositories/feedback-rating.repository'; +import { ClientsId } from '../../../src/repositories/interfaces/clients.entity.interface'; +import { UsersId } from '../../../src/repositories/interfaces/users.entity.interface'; -const testValidClientsId = mockId.clients.valid[0]; -const testValidUsersId = mockId.users.valid[0]; +const testValidClientId = mockId.clients.valid[0] as ClientsId; +const testValidUserId = mockId.users.valid[0] as UsersId; const testTimestamp = '2025-01-01T14:00:08.000Z'; jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ @@ -73,8 +75,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const testParam_id = mockId.feedback.valid[0]; const testResult: FeedbackResponseDTO | null = { feedback_id: testParam_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, rating: dbData_Feedback[2], term_accepted: dbData_Feedback[3], message: dbData_Feedback[4], @@ -108,8 +110,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn upsertInTa(), result: new Feedback on existing FeedbackRating', async () => { const app = createTestApp([ - MockUtils.injectTestClientId(mockId.clients.valid[0]), - MockUtils.injectTestUserId(mockId.users.valid[1]) + MockUtils.injectTestClientId(testValidClientId), + MockUtils.injectTestUserId(mockId.users.valid[1] as UsersId) ], router, apiUrl); const email = dbTestData.getUsersInsertSql().values[7]; // name, 2nd insert-row const testParam_dto: FeedbackRequestCreateDTO = { @@ -124,8 +126,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const testResult: FeedbackResponseDTO = { feedback_id: mockId.feedback.new[0], - client_id: testValidClientsId, - user_id: mockId.users.valid[1], + client_id: testValidClientId, + user_id: mockId.users.valid[1] as UsersId, rating: testParam_dto.rating, rating_average_new: mockResult_average_rating, term_accepted: testParam_dto.term_accepted, @@ -144,8 +146,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn upsertInTa(), result: new Feedback & FeedbackRating', async () => { const app = createTestApp([ - MockUtils.injectTestClientId(mockId.clients.valid[1]), - MockUtils.injectTestUserId(mockId.users.valid[0]) + MockUtils.injectTestClientId(mockId.clients.valid[1] as ClientsId), + MockUtils.injectTestUserId(testValidUserId) ], router, apiUrl); const email = dbTestData.getUsersInsertSql().values[7]; const testParam_dto: FeedbackRequestCreateDTO = { @@ -159,8 +161,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const testResult: FeedbackResponseDTO = { feedback_id: mockId.feedback.new[0], - client_id: mockId.clients.valid[1], - user_id: mockId.users.valid[0], + client_id: mockId.clients.valid[1] as ClientsId, + user_id: testValidUserId, rating: testParam_dto.rating, rating_average_new: testParam_dto.rating, term_accepted: testParam_dto.term_accepted, @@ -184,8 +186,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn upsertInTa(), result: update existing Feedback & FeedbackRating', async () => { const app = createTestApp([ - MockUtils.injectTestClientId(mockId.clients.valid[0]), - MockUtils.injectTestUserId(mockId.users.valid[0]) + MockUtils.injectTestClientId(testValidClientId), + MockUtils.injectTestUserId(testValidUserId) ], router, apiUrl); const email = dbTestData.getUsersInsertSql().values[1]; const testParam_dto: FeedbackRequestCreateDTO = { @@ -204,8 +206,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const testResult: FeedbackResponseDTO = { feedback_id: mockId.feedback.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: testValidClientId, + user_id: testValidUserId, rating: testParam_dto.rating, rating_average_new: mockResult_average_rating, term_accepted: testParam_dto.term_accepted, @@ -230,8 +232,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn upsertInTa(), result: Exception on Feedback + Rollback', async () => { const testParam_dto: FeedbackCreateDTO = { - client_id: mockId.clients.valid[1], - user_id: mockId.users.valid[0], + client_id: mockId.clients.valid[1] as ClientsId, + user_id: testValidUserId, rating: 5, term_accepted: true, message: 'test-feedback-message-new-client[1]' @@ -247,15 +249,15 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { .rejects.toThrow(new DBQueryErrorException(mockError)); const testResponse_Feedback = - await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1]}); + await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1] as ClientsId}); expect(testResponse_Feedback).toBe(null); // rollback => no insert }) test('Repository process fn upsertInTa(), result: Exception on FeedbackRating + Rollback', async () => { const testParam_dto: FeedbackCreateDTO = { - client_id: mockId.clients.valid[1], - user_id: mockId.users.valid[0], + client_id: mockId.clients.valid[1] as ClientsId, + user_id: testValidUserId, rating: 5, term_accepted: true, message: 'test-feedback-message-new-client[1]' @@ -267,7 +269,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { await dbTestSetup.addTestData(); const testResponse_Feedback = - await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1]}); + await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1] as ClientsId}); const testResponse_FeedbackRating = await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1]); @@ -291,8 +293,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const testResult: FeedbackResponseDTO = { feedback_id: testParam_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, rating: dbData_Feedback[2], term_accepted: dbData_Feedback[3], message: dbData_Feedback[4], @@ -380,7 +382,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: FeedbackFilterDTO = { - client_id: [mockId.clients.valid[0], 'invalid-id'] + client_id: [testValidClientId, 'invalid-id' as ClientsId] } const testError = [{ type: 'field', @@ -400,7 +402,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: FeedbackFilterDTO = { - user_id: ['invalid-id-0', mockId.clients.valid[0], 'invalid-id-1'] + user_id: ['invalid-id-0', testValidUserId, 'invalid-id-1'] as UsersId[] } const testError = [ { @@ -542,8 +544,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { let testData: Partial; beforeEach(() => { app = createTestApp([ - MockUtils.injectTestClientId(mockId.clients.valid[0]), - MockUtils.injectTestUserId(mockId.users.valid[0]) + MockUtils.injectTestClientId(testValidClientId), + MockUtils.injectTestUserId(testValidUserId) ], router, apiUrl); testData = { user_email: 'max.muster@test.com', @@ -700,8 +702,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { let app: any; beforeEach(() => { app = createTestApp([ - MockUtils.injectTestClientId(mockId.clients.valid[0]), - MockUtils.injectTestUserId(mockId.users.valid[0]) + MockUtils.injectTestClientId(testValidClientId), + MockUtils.injectTestUserId(testValidUserId) ], router, apiUrl); }) diff --git a/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts b/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts index 8471bf2..9df3e7a 100644 --- a/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts +++ b/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts @@ -12,11 +12,13 @@ import * as CommonUtils from '../../../src/utils/common.utils'; import rateLimitsService from "../../../src/services/rate-limits.service"; import demoLimitsService from "../../../src/services/demo-limits.service"; import { DemoLimitsCountDTO, DemoLimitsResponseDTO } from "../../../src/dtos/demo-limits.dto"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; jest.setTimeout(60000); -const testValidClientsId = mockId.clients.valid[0]; -const testValidUsersId = mockId.users.valid[0]; +const testValidClientId = mockId.clients.valid[0] as ClientsId; +const testValidUserId = mockId.users.valid[0] as UsersId; describe('Integration-tests (repository), priority: entity RateLimits', () => { @@ -40,7 +42,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { test('Repository process fn getRateLimitCount(), params: , result: "SUCCESS"', async () => { const dto: RateLimitsCountDTO = { - client_id: mockId.clients.valid[0], + client_id: testValidClientId, day: '2025-01-01' }; @@ -66,8 +68,8 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { test('Repository process fn createRateLimit(), result: "SUCCESS"', async () => { const testTimestamp = '2025-01-02T14:00:05.000Z'; const mockParam_dto: RateLimitsCreateDTO = { - client_id: testValidClientsId, - user_id: testValidUsersId + client_id: testValidClientId, + user_id: testValidUserId }; const dateUTC = CommonUtils.getDateUTC(new Date(testTimestamp)); @@ -78,8 +80,8 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { const testResponse = await rateLimitsService.createRateLimit(mockParam_dto); const mockResponse: RateLimitsResponseDTO = { rate_limit_id: 2, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, day: '2025-01-02', count: 1, last_modified: testTimestamp @@ -92,8 +94,8 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { test('Repository process fn updateRateLimit(), result: "SUCCESS"', async () => { const testTimestamp = '2025-01-01T22:22:22.000Z'; const dto: RateLimitsUpdateDTO = { - client_id: testValidClientsId, - user_id: testValidUsersId + client_id: testValidClientId, + user_id: testValidUserId }; jest.spyOn(CommonUtils, 'getDateUTC').mockReturnValue(testTimestamp); @@ -103,8 +105,8 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { const testResponse = await rateLimitsService.updateRateLimit(dto); const mockResponse: RateLimitsResponseDTO = { rate_limit_id: 1, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, day: '2025-01-01', count: 2, last_modified: testTimestamp @@ -117,8 +119,8 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { test('Repository process fn updateRateLimit(), result: null', async () => { const testTimestamp = '2025-01-02T22:22:22.000Z'; const dto: RateLimitsUpdateDTO = { - client_id: testValidClientsId, - user_id: testValidUsersId + client_id: testValidClientId, + user_id: testValidUserId }; jest.spyOn(CommonUtils, 'getDateUTC').mockReturnValue(testTimestamp); diff --git a/backend/tests/integration-tests/repositories/tickets.integration.test.ts b/backend/tests/integration-tests/repositories/tickets.integration.test.ts index e78d98e..7d806db 100644 --- a/backend/tests/integration-tests/repositories/tickets.integration.test.ts +++ b/backend/tests/integration-tests/repositories/tickets.integration.test.ts @@ -22,8 +22,8 @@ import { CloudService } from "../../../src/services/cloud.service"; import { TicketOption } from "../../../src/utils/enums/ticket-option.enum"; import { DeviceOption } from "../../../src/utils/enums/device-option.enum"; -const testValidClientsId = mockId.clients.valid[0]; -const testValidUsersId = mockId.users.valid[0]; +const testValidClientId = mockId.clients.valid[0] as ClientsId; +const testValidUserId = mockId.users.valid[0] as UsersId; const testTimestamp = '2025-01-01T14:00:04.000Z'; jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ @@ -31,13 +31,13 @@ jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ })); jest.mock('../../../src/middleware/auth.client.middleware', () => ({ authClient: jest.fn(() => (req: Request, res: Response, next: NextFunction) => { - (req as any).apiClients = { client_id: testValidClientsId }; + (req as any).apiClients = { client_id: testValidClientId }; next(); }) })); jest.mock('../../../src/middleware/auth.user.middleware', () => ({ authUser: jest.fn(() => (req: Request, res: Response, next: NextFunction) => { - (req as any).apiUsers = { user_id: testValidUsersId }; + (req as any).apiUsers = { user_id: testValidUserId }; next(); }) })); @@ -49,6 +49,8 @@ jest.mock('../../../src/middleware/observe.middleware.ts', () => ({ })) import app from '../../../src/app'; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.setTimeout(60000); @@ -82,9 +84,9 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testParam_id = mockId.tickets.valid[0]; const testResult: TicketsResponseExtendedDTO = { ticket_id: testParam_id, - client_id: testValidClientsId, + client_id: testValidClientId, client_name: testClientsName, - user_id: testValidUsersId, + user_id: testValidUserId, user_email: testUsersEmail, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, @@ -111,8 +113,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO[] = [ { ticket_id: mockId.tickets.valid[0], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -127,8 +129,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }, { ticket_id: mockId.tickets.valid[1], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -152,7 +154,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Repository process fn findByFilter(), params: result: null', async () => { const testParam_dto: TicketsFilterDTO = { - client_id: mockId.tickets.invalid[0] + client_id: mockId.clients.invalid[0] as ClientsId }; const testResult: TicketsResponseDTO[] | null = null; @@ -167,14 +169,14 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Repository process fn findByFilter(), params: result: "SUCCESS"', async () => { const testParam_dto: TicketsFilterDTO = { - user_id: [testValidUsersId, mockId.tickets.invalid[0]], + user_id: [testValidUserId, mockId.users.invalid[0] as UsersId], status: TicketStatus.ISSUED }; const testResult: TicketsResponseDTO[] = [ { ticket_id: mockId.tickets.valid[0], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -189,8 +191,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }, { ticket_id: mockId.tickets.valid[1], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -222,8 +224,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO[] = [ { ticket_id: mockId.tickets.valid[0], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -238,8 +240,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }, { ticket_id: mockId.tickets.valid[1], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -271,8 +273,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO[] = [ { ticket_id: mockId.tickets.valid[0], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -287,8 +289,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }, { ticket_id: mockId.tickets.valid[1], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -320,8 +322,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO[] = [ { ticket_id: mockId.tickets.valid[0], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -336,8 +338,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }, { ticket_id: mockId.tickets.valid[1], - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -393,8 +395,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO = { ticket_id: testNewParam_ticket_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: testParam_dto.title, @@ -434,8 +436,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO = { ticket_id: testNewParam_ticket_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: testParam_dto.title, @@ -492,8 +494,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { const testResult: TicketsResponseDTO = { ticket_id: testNewParam_ticket_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: testParam_dto.title, @@ -541,8 +543,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { info_os: 'Android 15', info_device: DeviceOption.MOBILE, ticket_id: testParam_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, last_modified: mockTimestampNoPaths, created_on: mockTimestampNoPaths }; @@ -578,8 +580,8 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { info_os: 'Windows 11', info_device: DeviceOption.COMPUTER, ticket_id: testParam_id, - client_id: testValidClientsId, - user_id: testValidUsersId, + client_id: testValidClientId, + user_id: testValidUserId, last_modified: testTimestamp, created_on: testTimestamp }; @@ -840,7 +842,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: TicketsFilterDTO = { - client_id: [mockId.clients.valid[0], 'invalid-id'] + client_id: [mockId.clients.valid[0], 'invalid-id'] as ClientsId[] } const testError = [{ type: 'field', @@ -860,7 +862,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: TicketsFilterDTO = { - user_id: ['invalid-id-0', mockId.clients.valid[0], 'invalid-id-1'] + user_id: ['invalid-id-0', mockId.users.valid[0], 'invalid-id-1'] as UsersId[] } const testError = [ { diff --git a/backend/tests/integration-tests/repositories/users.integration.test.ts b/backend/tests/integration-tests/repositories/users.integration.test.ts index 718e666..be26dc1 100644 --- a/backend/tests/integration-tests/repositories/users.integration.test.ts +++ b/backend/tests/integration-tests/repositories/users.integration.test.ts @@ -6,7 +6,7 @@ import { ErrorStatusCodes } from '../../../src/utils/errorStatusCodes.utils'; import { DBTestSetup } from "../../db-container.setup"; import { runMigrations } from '../../db-migrations.setup'; import { UsersUpdateDTO, UsersFilterDTO, UsersResponseDTO, UsersCreateDTO, UsersFlagUpdateDTO } from "../../../src/dtos/users.dto"; -import { Users } from "../../../src/repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import { UserStatus } from "../../../src/utils/enums/user-status.enum"; import { Flag } from "../../../src/utils/enums/flag.enum"; import { default as mockId } from "../../mock-data/id.mock-data.json"; @@ -23,6 +23,8 @@ jest.mock('../../../src/middleware/maintenance.middleware', () => ({ import app from '../../../src/app'; jest.setTimeout(60000); + +const mockValidUserId = mockId.users.valid[0] as UsersId; const testTimestamp = '2025-01-01T14:00:03.000Z'; describe('Integration-tests (repository), priority: entity Users', () => { @@ -48,7 +50,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { describe('Testing valid fn calls', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = mockId.users.valid[0]; + const testParam_id = mockValidUserId; const testResult: UsersResponseDTO = { user_id: testParam_id, email: 'max.mustermann@yqni13.com', @@ -69,7 +71,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { test('Repository process fn findByEmail(), result: "SUCCESS"', async () => { const testParam_email = 'max.mustermann@yqni13.com'; const testResult: UsersResponseDTO = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: testParam_email, status: UserStatus.ACTIVE, flag: null, @@ -86,10 +88,10 @@ describe('Integration-tests (repository), priority: entity Users', () => { }) test('Repository process fn findAll(), result: "SUCCESS"', async () => { - const testParam_id = mockId.users.valid[0]; + const testParam_id = mockValidUserId; const testResult: Users[] = [ { - user_id: mockId.users.valid[1], + user_id: mockId.users.valid[1] as UsersId, email: 'aurora.otsuki@yqni13.com', status: UserStatus.ACTIVE, flag: null, @@ -136,7 +138,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { status: UserStatus.ACTIVE }; const testResult: UsersResponseDTO[] = [{ - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'max.mustermann@yqni13.com', status: UserStatus.ACTIVE, flag: null, @@ -161,7 +163,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { last_modified: ['2024-12-01T10:00:00.000Z', '2025-12-01T14:00:00.000Z'] }; const testResult: UsersResponseDTO[] = [{ - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'max.mustermann@yqni13.com', status: UserStatus.ACTIVE, flag: null, @@ -181,7 +183,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { }) test('Repository process fn create(), result: "SUCCESS"', async () => { - const testParam_id = mockId.users.new[0]; + const testParam_id = mockId.users.new[0] as UsersId; const testParam_dto: UsersCreateDTO = { email: 'new-user@test.com' }; @@ -234,7 +236,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { }) test('Repository process fn updateFlag(), result: "SUCCESS"', async () => { - const testParam_id = mockId.users.valid[0]; + const testParam_id = mockValidUserId; const testParam_dto: UsersFlagUpdateDTO = { flag: Flag.WARNING }; diff --git a/backend/tests/unit-tests/middleware/auth.client.middleware.test.ts b/backend/tests/unit-tests/middleware/auth.client.middleware.test.ts index 27c0029..f90c782 100644 --- a/backend/tests/unit-tests/middleware/auth.client.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/auth.client.middleware.test.ts @@ -1,5 +1,5 @@ import { ForbiddenApiKeyException, MissingApiKeyException } from './../../../src/utils/exceptions/auth.exception'; -import { Clients } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import clientsService from "../../../src/services/clients.service"; import { default as mockId } from "../../mock-data/id.mock-data.json"; import { secrets } from "../../../src/utils/secrets.utils"; @@ -11,6 +11,7 @@ import { ErrorStatusCodes } from "../../../src/utils/errorStatusCodes.utils"; describe('Unit-tests (middleware), priority: fn authClient()', () => { + const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockTimestamp = '2025-01-01T14:00:00.000Z'; const req: any = { header: jest.fn() }; const res: any = {}; @@ -24,7 +25,7 @@ describe('Unit-tests (middleware), priority: fn authClient()', () => { test('Verfiy client, params: valid ', async () => { const mockApiKey = 'valid_api_key'; const mockClient: Clients = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, name: 'valid_clients_test_name', api_key_hash: secrets.TEST_APIKEY_HASH, status: ApiKeyStatus.ACTIVE, diff --git a/backend/tests/unit-tests/middleware/auth.user.middleware.test.ts b/backend/tests/unit-tests/middleware/auth.user.middleware.test.ts index dea99fd..bbd6020 100644 --- a/backend/tests/unit-tests/middleware/auth.user.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/auth.user.middleware.test.ts @@ -1,7 +1,7 @@ import { default as mockId } from "../../mock-data/id.mock-data.json"; import * as CommonUtils from "../../../src/utils/common.utils"; import { authUser } from "../../../src/middleware/auth.user.middleware"; -import { Users } from "../../../src/repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import { UserStatus } from "../../../src/utils/enums/user-status.enum"; import usersService from "../../../src/services/users.service"; import { UsersResponseDTO } from "../../../src/dtos/users.dto"; @@ -10,6 +10,7 @@ import { Flag } from "../../../src/utils/enums/flag.enum"; describe('Unit-tests (middleware), priority: fn authUser()', () => { + const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:00.000Z'; const res: any = {}; const next = jest.fn(); @@ -22,7 +23,7 @@ describe('Unit-tests (middleware), priority: fn authUser()', () => { test('Verfiy user, params: valid for existing user', async () => { const mockUserEmail = 'valid_user@test.com'; const mockUser: Users = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: mockUserEmail, status: UserStatus.ACTIVE, flag: null, @@ -47,7 +48,7 @@ describe('Unit-tests (middleware), priority: fn authUser()', () => { const mockUserEmail = 'non-existing-user@test.com'; const mockUser: Users | null = null; const mockNewUser: UsersResponseDTO = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: mockUserEmail, status: UserStatus.ACTIVE, flag: null, @@ -75,7 +76,7 @@ describe('Unit-tests (middleware), priority: fn authUser()', () => { test('Verify user, error: InvalidUsersException', async () => { const mockUserEmail = 'valid-user@test.com'; const mockUser: Users = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: mockUserEmail, status: UserStatus.ACTIVE, flag: Flag.ERROR, @@ -99,7 +100,7 @@ describe('Unit-tests (middleware), priority: fn authUser()', () => { test('Verify user, error: BlockedUsersException', async () => { const mockUserEmail = 'valid-user@test.com'; const mockUser: Users = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: mockUserEmail, status: UserStatus.BLACKLISTED, flag: Flag.ERROR, diff --git a/backend/tests/unit-tests/middleware/engines.middleware.test.ts b/backend/tests/unit-tests/middleware/engines.middleware.test.ts index 957dafb..64cea0e 100644 --- a/backend/tests/unit-tests/middleware/engines.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/engines.middleware.test.ts @@ -11,20 +11,21 @@ import { default as mockId } from "../../mock-data/id.mock-data.json"; // Ensure correct type by converting secret to number via unary + operator. import { secrets } from "../../../src/utils/secrets.utils"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; describe('Unit-tests (middleware), priority: class RateLimitsEngine', () => { + const mockValidClientId = mockId.clients.valid[0] as ClientsId; + const mockValidUserId = mockId.users.valid[0] as UsersId; + describe('Testing valid fn calls', () => { - let mockValidClientsId: string; - let mockValidUsersId: string; let mockParam_data: RateLimitsData; beforeEach(() => { - mockValidClientsId = mockId.clients.valid[0], - mockValidUsersId = mockId.users.valid[0]; mockParam_data = { - client_id: mockValidClientsId, - user_id: mockValidUsersId + client_id: mockValidClientId, + user_id: mockValidUserId }; }); @@ -57,7 +58,7 @@ describe('Unit-tests (middleware), priority: class RateLimitsEngine', () => { retryAfter: '2025-01-02T00.00.01.000Z', penalty: { type: Violation.CLIENTSFLAG, - id: mockValidClientsId, + id: mockValidClientId, penaltyValue: null } }; @@ -89,7 +90,7 @@ describe('Unit-tests (middleware), priority: class RateLimitsEngine', () => { const engine = new RateLimitsEngine([rule], count); const mockResponse = null; - const testFn = await engine.process({ client_id: 'demo', user_id: 'demo' }); + const testFn = await engine.process({ client_id: 'demo' as ClientsId, user_id: 'demo' as UsersId }); expect(testFn).toBe(mockResponse); }) @@ -107,7 +108,7 @@ describe('Unit-tests (middleware), priority: class RateLimitsEngine', () => { const engine = new RateLimitsEngine([rule], count); const mockResponse = response; - const testFn = await engine.process({ client_id: 'demo', user_id: 'demo' }); + const testFn = await engine.process({ client_id: 'demo' as ClientsId, user_id: 'demo' as UsersId }); expect(testFn).toBe(mockResponse); }) diff --git a/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts b/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts index a41a50f..ff7d58c 100644 --- a/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts @@ -9,6 +9,7 @@ import { import { TicketsResponseDTO } from "../../../../src/dtos/tickets.dto"; import { RateLimitsData, RateLimitsResponse } from "../../../../src/middleware/interfaces/rate-limits.interface.middleware"; import * as CommonUtils from "../../../../src/utils/common.utils"; +import { default as mockId } from "../../../mock-data/id.mock-data.json"; import ticketsService from "../../../../src/services/tickets.service"; import { TicketStatus } from "../../../../src/utils/enums/ticket-status.enum"; import rateLimitsService from "../../../../src/services/rate-limits.service"; @@ -16,30 +17,33 @@ import demoLimitsService from "../../../../src/services/demo-limits.service"; import { Violation } from "../../../../src/utils/enums/violations.enum"; import { MaintenanceMode } from "../../../../src/utils/enums/maintenance-mode.enum"; import { TicketOption } from "../../../../src/utils/enums/ticket-option.enum"; +import { ClientsId } from "../../../../src/repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../../../../src/repositories/interfaces/users.entity.interface"; // Ensure correct type by converting secret to number via unary + operator. import { secrets } from "../../../../src/utils/secrets.utils" describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () => { - const mockValidClientsId = 'valid_clients_test_id'; - const mockValidUsersId = 'valid_users_test_id'; + const mockValidClientId = mockId.clients.valid[0] as ClientsId; + const mockValidUserId = mockId.users.valid[0] as UsersId; let mockRetryAfter: string; let mockParam_data: RateLimitsData; let mockBurstContext: TicketsResponseDTO[]; beforeEach(() => { mockRetryAfter = '2025-01-02T00:00:01.000Z'; mockParam_data = { - client_id: mockValidClientsId, - user_id: mockValidUsersId + client_id: mockValidClientId, + user_id: mockValidUserId }; mockBurstContext = [ { ticket_id: 'valid_tickets_test_id_0', - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, + title: 'test_title_0', message: 'test_message_0', flag: null, last_modified: '2025-01-01T14:00:04.000Z', @@ -47,10 +51,11 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () }, { ticket_id: 'valid_tickets_test_id_1', - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, + title: 'test_title_1', message: 'test_message_1', flag: null, last_modified: '2025-01-01T14:00:11.000Z', @@ -82,10 +87,11 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () const mockContext = structuredClone(mockBurstContext); mockContext.push({ ticket_id: 'valid_tickets_test_id_2', - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, + title: 'test_title_2', message: 'test_message_2', flag: null, last_modified: '2025-01-01T14:00:23.000Z', @@ -129,10 +135,11 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () const mockContext = structuredClone(mockBurstContext); mockContext.push({ ticket_id: 'valid_tickets_test_id_2', - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, + title: 'test_title_2', message: 'test_message_2', flag: null, last_modified: '2025-01-01T14:00:23.000Z', @@ -196,7 +203,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () retryAfter: mockRetryAfter, penalty: { type: Violation.CLIENTSFLAG, - id: 'valid_clients_test_id', + id: mockValidClientId, penaltyValue: null } }; @@ -250,7 +257,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () retryAfter: mockRetryAfter, penalty: { type: Violation.USERSFLAG, - id: 'valid_users_test_id', + id: mockValidUserId, penaltyValue: null } }; diff --git a/backend/tests/unit-tests/models/clients.models.test.ts b/backend/tests/unit-tests/models/clients.models.test.ts index ecd6f19..3ec95db 100644 --- a/backend/tests/unit-tests/models/clients.models.test.ts +++ b/backend/tests/unit-tests/models/clients.models.test.ts @@ -5,13 +5,14 @@ import { import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; import clientsModel from "../../../src/models/clients.model"; -import { Clients } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { ApiKeyStatus } from "../../../src/utils/enums/api-key-status.enum"; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockVar_apiKey = clientsModel._generateApiKeyObj(); const mockTimestamp = '2025-01-01T14:00:02.000Z'; let mockData: Clients = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, name: 'testclient', api_key_hash: mockVar_apiKey.keyHash, status: ApiKeyStatus.ACTIVE, @@ -52,10 +53,10 @@ describe('Unit-tests (model), priority: entity Clients', () => { describe('Testing valid fn calls', () => { test('Generate new object Clients + raw key', () => { + const mockParam_id = mockValidClientId; const mockParam_dto: ClientsCreateDTO = { name: 'TESTCLIENT' }; - const mockParam_id = mockId.clients.valid[0]; const mockApiKeyObj = { keyRaw: 'test-key', keyHash: 'hashed-test-key' }; const mockClient: Clients = { client_id: mockParam_id, diff --git a/backend/tests/unit-tests/models/feedback-rating.model.test.ts b/backend/tests/unit-tests/models/feedback-rating.model.test.ts index 7d205a6..521ccbd 100644 --- a/backend/tests/unit-tests/models/feedback-rating.model.test.ts +++ b/backend/tests/unit-tests/models/feedback-rating.model.test.ts @@ -1,9 +1,13 @@ import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../../../src/dtos/feedback-rating.dto"; import feedbackRatingModel from "../../../src/models/feedback-rating.model"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { FeedbackRating } from "../../../src/repositories/interfaces/feedback-rating.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as mockId from "../../mock-data/id.mock-data.json"; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:09.000Z'; describe('Unit-tests (model), priority: entity FeedbackRating', () => { @@ -14,7 +18,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { test('Generate new object, params: are undefined', () => { const mockParam_dto: FeedbackRatingCreateDTO = { - client_id: mockId.clients.valid[0] + client_id: mockValidClientId }; jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); @@ -32,7 +36,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { test('Generate new object, params: have values', () => { const mockParam_dto: FeedbackRatingCreateDTO = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: 1, rating_sum: 5 }; @@ -78,7 +82,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { test('Convert entity to dto + map average rating, result: FeedbackRatingExtendedResponseDTO', () => { const mockParam_entity: FeedbackRating = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: 16, rating_sum: 67, last_modified: mockTimestamp, @@ -97,7 +101,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { test('Convert entity to dto + map average rating, result: FeedbackRatingResponseDTO', () => { const mockParam_entity: FeedbackRating = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: 16, rating_sum: 67, last_modified: mockTimestamp, @@ -121,7 +125,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { test('Convert entity[] to dto[] + map average rating, result: FeedbackRatingExtendedResponseDTO[]', () => { const mockParam_entities: FeedbackRating[] = [{ - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: 16, rating_sum: 67, last_modified: mockTimestamp, @@ -141,7 +145,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { test('Convert entity[] to dto[] + map average rating, result: FeedbackRatingResponseDTO[]', () => { const mockParam_entities: FeedbackRating[] = [{ - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: 16, rating_sum: 67, last_modified: mockTimestamp, diff --git a/backend/tests/unit-tests/models/feedback.model.test.ts b/backend/tests/unit-tests/models/feedback.model.test.ts index f5b3d5e..2c84fe7 100644 --- a/backend/tests/unit-tests/models/feedback.model.test.ts +++ b/backend/tests/unit-tests/models/feedback.model.test.ts @@ -1,10 +1,14 @@ import { FeedbackRatingResponseDTO } from "../../../src/dtos/feedback-rating.dto"; import { FeedbackCreateDTO, FeedbackResponseDTO, FeedbackUpdateReviewDTO } from "../../../src/dtos/feedback.dto"; import feedbackModel from "../../../src/models/feedback.model"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { Feedback } from "../../../src/repositories/interfaces/feedback.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as mockId from "../../mock-data/id.mock-data.json"; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:08.000Z'; describe('Unit-tests (model), priority: entity Feedback', () => { @@ -13,8 +17,8 @@ describe('Unit-tests (model), priority: entity Feedback', () => { beforeEach(() => { mockEntity = { feedback_id: mockId.feedback.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, rating: 5, term_accepted: true, message: 'valid_feedback_test_message', @@ -122,8 +126,8 @@ describe('Unit-tests (model), priority: entity Feedback', () => { originalEntity.reviewed_on = mockTimestamp; const newEntity: Feedback = { feedback_id: mockId.feedback.valid[1], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[1], + client_id: mockValidClientId, + user_id: mockId.users.valid[1] as UsersId, rating: 4, term_accepted: false, message: 'another_valid_feedback_test_message', diff --git a/backend/tests/unit-tests/models/rate-limits.models.test.ts b/backend/tests/unit-tests/models/rate-limits.models.test.ts index 691aaee..17335a0 100644 --- a/backend/tests/unit-tests/models/rate-limits.models.test.ts +++ b/backend/tests/unit-tests/models/rate-limits.models.test.ts @@ -1,13 +1,18 @@ import { RateLimitsCreateDTO } from "../../../src/dtos/rate-limits.dto"; import demoLimitsModel from "../../../src/models/demo-limits.model"; import rateLimitsModel from "../../../src/models/rate-limits.model"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; import { RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; describe('Unit-tests (model), priority: entity RateLimits', () => { + const mockValidClientId = mockId.clients.valid[0] as ClientsId; + const mockValidUserId = mockId.users.valid[0] as UsersId; + describe('Priority: fn mapCounts()', () => { describe('Testing valid fn calls', () => { @@ -16,16 +21,16 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { const mockParam_data: RateLimits[] = [ { rate_limit_id: mockId.rate_limits.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-01-01', count: 3, last_modified: '2025-01-01T14:00:05.000Z' }, { rate_limit_id: mockId.rate_limits.valid[1], - client_id: mockId.clients.valid[0], - user_id: 'another_valid_users_test_id', + client_id: mockValidClientId, + user_id: 'another_valid_users_test_id' as UsersId, day: '2025-01-01', count: 2, last_modified: '2025-01-01T14:00:05.000Z' @@ -42,8 +47,8 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { const mockParam_data: RateLimits[] = [ { rate_limit_id: mockId.rate_limits.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-01-01', count: 4, last_modified: '2025-01-01T14:00:05.000Z' @@ -73,8 +78,8 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { test('Map timestamp/date values to DTO, result: entity Partial', () => { const mockParam_dto: RateLimitsCreateDTO = { - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, } const mockDate = '2025-01-01'; const mockTimestamp = '2025-01-01T14:00:05.000Z'; diff --git a/backend/tests/unit-tests/models/tickets.model.test.ts b/backend/tests/unit-tests/models/tickets.model.test.ts index 3ab61f4..be44c41 100644 --- a/backend/tests/unit-tests/models/tickets.model.test.ts +++ b/backend/tests/unit-tests/models/tickets.model.test.ts @@ -8,7 +8,11 @@ import { Readable } from 'stream'; import { FilesService } from "../../../src/services/files.service"; import { TicketOption } from "../../../src/utils/enums/ticket-option.enum"; import { PermissionException } from "../../../src/utils/exceptions/auth.exception"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:04.000Z'; describe('Unit-tests (model), priority: entity Tickets', () => { @@ -52,8 +56,8 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Generate new object, priority: no files', async () => { const mockParam_id = mockId.tickets.new[0]; const mockParam_dto: TicketsCreateDTO = { - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, option: TicketOption.SUPPORT, title: 'test-title', message: 'test-message' @@ -83,8 +87,8 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Generate new object, priority: single file', async () => { const mockParam_id = mockId.tickets.new[0]; const mockParam_dto: TicketsCreateDTO = { - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, option: TicketOption.SUPPORT, title: 'test-title', message: 'test-message' @@ -119,8 +123,8 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Generate new object, priority: multiple files', async () => { const mockParam_id = mockId.tickets.new[0]; const mockParam_dto: TicketsCreateDTO = { - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, option: TicketOption.SUPPORT, title: 'test-title', message: 'test-message' @@ -191,8 +195,8 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Check for file deletion => call FilesService.deleteFiles(), params: ', async () => { const mockParam_dto: TicketsResponseDTO = { ticket_id: mockId.tickets.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -215,8 +219,8 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Check for file deletion => does NOT call FilesService.deleteFiles(), params: ', async () => { const mockParam_dto: TicketsResponseDTO = { ticket_id: mockId.tickets.valid[1], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title-without-resource_paths', @@ -240,8 +244,8 @@ describe('Unit-tests (model), priority: entity Tickets', () => { beforeEach(() => { mockParam_dto = { ticket_id: mockId.tickets.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.CLOSED, option: TicketOption.SUPPORT, title: 'test-title', diff --git a/backend/tests/unit-tests/models/users.models.test.ts b/backend/tests/unit-tests/models/users.models.test.ts index c40b5da..3698476 100644 --- a/backend/tests/unit-tests/models/users.models.test.ts +++ b/backend/tests/unit-tests/models/users.models.test.ts @@ -1,10 +1,11 @@ -import { Users } from "../../../src/repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import { UsersCreateDTO } from "../../../src/dtos/users.dto"; import usersModel from "../../../src/models/users.model"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; import { UserStatus } from "../../../src/utils/enums/user-status.enum"; +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-03T14:00:03.000Z'; describe('Unit-tests (model), priority: entity Users', () => { @@ -24,7 +25,7 @@ describe('Unit-tests (model), priority: entity Users', () => { const testFn = usersModel.generateUser(mockParam_dto); const expectResult: Users = { - user_id: mockParam_id, + user_id: mockValidUserId, email: mockParam_dto.email, status: UserStatus.ACTIVE, flag: null, diff --git a/backend/tests/unit-tests/repositories/clients.repositories.test.ts b/backend/tests/unit-tests/repositories/clients.repositories.test.ts index 41d4925..4b1587d 100644 --- a/backend/tests/unit-tests/repositories/clients.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/clients.repositories.test.ts @@ -10,7 +10,7 @@ import { DBConnection } from "../../../src/configs/db"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as MockUtils from "../../common.test-utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; -import { Clients } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { Clients, ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { ApiKeyStatus } from "../../../src/utils/enums/api-key-status.enum"; import clientsRepository from "../../../src/repositories/clients.repository"; import clientsModel from "../../../src/models/clients.model"; @@ -26,11 +26,11 @@ jest.mock("../../../src/configs/db", () => { } }); -const mockVar_id = '9e024539-32e8-4317-8007-84a3956e6b57'; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockVar_keyHash = secrets.TEST_APIKEY_HASH; const mockTimestamp = '2025-01-01T14:00:02.000Z'; const mockData: Clients = { - client_id: mockVar_id, + client_id: mockValidClientId, name: 'valid_clients_test_name', api_key_hash: mockVar_keyHash, status: ApiKeyStatus.ACTIVE, @@ -56,18 +56,18 @@ describe('Unit-tests (repository), priority: entity Clients', () => { test('Return data for existing entry, params: valid ', async () => { const mockResult: Clients | null = structuredClone(mockData); const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await clientsRepository.findById(mockVar_id); + const testFn = await clientsRepository.findById(mockValidClientId); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); expect(mockClient.query).toHaveBeenCalledWith( expect.stringContaining(sql), - expect.arrayContaining([mockVar_id]) + expect.arrayContaining([mockValidClientId]) ); }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockResult: Clients | null = null; const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await clientsRepository.findById(mockParam_id); @@ -84,7 +84,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; const mockResult: Clients | null = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -161,7 +161,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { test('Return data for existing entry, params: ', async () => { const mockParam_name = 'existing_clients_test_name'; const mockResult: ClientsStatusResponseDTO = { - client_id: 'valid_clients_test_id', + client_id: mockValidClientId, name: mockParam_name, status: ApiKeyStatus.ACTIVE, last_use: mockTimestamp, @@ -219,7 +219,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { sql = `INSERT`; mockVar_apiKey = clientsModel._generateApiKeyObj(); mockParam_entity = { - client_id: mockVar_id, + client_id: mockValidClientId, name: 'valid_clients_test_name', api_key_hash: mockVar_apiKey.keyHash, status: ApiKeyStatus.ACTIVE, @@ -275,7 +275,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.clients.valid[0]; + const mockParam_id = mockValidClientId; const mockValues = [mockParam_dto.status, mockTimestamp, mockParam_id]; const mockResult: ClientsStatusResponseDTO = { @@ -298,7 +298,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { }) test('Return null for non-existing entry by invalid id', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockValues = [mockParam_dto.status, mockTimestamp, mockParam_id]; const mockResult = null; @@ -317,7 +317,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -341,7 +341,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.clients.valid[0]; + const mockParam_id = mockValidClientId; const mockValues = [mockParam_dto.flag, mockTimestamp, mockParam_id]; const mockResult: ClientsFlagResponseDTO | null = { @@ -363,7 +363,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockValues = [mockParam_dto.flag, mockTimestamp, mockParam_id]; const mockResult: ClientsFlagResponseDTO | null = null; @@ -382,7 +382,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult: ClientsFlagResponseDTO | null = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -408,7 +408,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.clients.valid[0]; + const mockParam_id = mockValidClientId; const mockValues = [mockTimestamp, mockParam_id]; jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); @@ -432,7 +432,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockValues = [mockTimestamp, mockParam_id]; const mockResult = null; @@ -451,7 +451,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); diff --git a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts index 8e1fea1..7ea99a7 100644 --- a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -6,6 +6,7 @@ import { default as mockId } from "../../mock-data/id.mock-data.json"; import feedbackRatingRepository from "../../../src/repositories/feedback-rating.repository"; import { DBConnection } from "../../../src/configs/db"; import { FeedbackRatingUpdateDTO } from "../../../src/dtos/feedback-rating.dto"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.mock("../../../src/configs/db", () => { return { @@ -15,9 +16,10 @@ jest.mock("../../../src/configs/db", () => { } }); +const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockTimestamp = '2025-01-01T14:00:09.000Z'; const mockData: FeedbackRating = { - client_id: mockId.clients.valid[0], + client_id: mockValidClientId, count: 16, rating_sum: 67, last_modified: mockTimestamp, @@ -146,7 +148,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { test('Return data for multiple existing entries', async () => { const mockData_entry0: FeedbackRating = structuredClone(mockData); const mockData_entry1: FeedbackRating = { - client_id: 'another_clients_test_id', + client_id: 'another_clients_test_id' as ClientsId, count: 5, rating_sum: 19, last_modified: mockTimestamp, @@ -201,7 +203,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { beforeEach(() => { sql = `INSERT`; mockParam_entity = { - client_id: mockId.clients.new[0], + client_id: mockValidClientId, count: 0, rating_sum: 0, last_modified: mockTimestamp, diff --git a/backend/tests/unit-tests/repositories/feedback.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts index db760fd..882fcc5 100644 --- a/backend/tests/unit-tests/repositories/feedback.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts @@ -6,6 +6,8 @@ import { Feedback } from "../../../src/repositories/interfaces/feedback.entity.i import { DBConnection } from "../../../src/configs/db"; import feedbackRepository from "../../../src/repositories/feedback.repository"; import { FeedbackFilterDTO, FeedbackUpdateReviewDTO } from "../../../src/dtos/feedback.dto"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.mock("../../../src/configs/db", () => { return { @@ -18,8 +20,8 @@ jest.mock("../../../src/configs/db", () => { const mockTimestamp = '2025-01-01T14:00:08.000Z'; const mockData: Feedback = { feedback_id: mockId.feedback.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockId.clients.valid[0] as ClientsId, + user_id: mockId.users.valid[0] as UsersId, rating: 5, term_accepted: true, message: 'valid_feedback_test_message', @@ -112,7 +114,9 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { }) test('Return null for non-existing entry, params: non-existing ', async () => { - const mockParam_dto = { user_id: [mockId.users.invalid[0], 'another_invalid_users_test_id'] }; + const mockParam_dto = { + user_id: [mockId.users.invalid[0], 'another_invalid_users_test_id'] as UsersId[] + }; const mockValues = mockParam_dto.user_id; const mockResult: Feedback[] | null = null; diff --git a/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts b/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts index c4915dd..4be3e16 100644 --- a/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts @@ -13,6 +13,8 @@ import { RateLimits } from "../../../src/repositories/interfaces/rate-limits.ent import { DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; import { DemoLimitsCountDTO, DemoLimitsResponseDTO, DemoLimitsUpdateDTO } from "../../../src/dtos/demo-limits.dto"; import demoLimitsRepository from "../../../src/repositories/demo-limits.repository"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; jest.mock("../../../src/configs/db", () => { return { @@ -24,8 +26,8 @@ jest.mock("../../../src/configs/db", () => { const mockTimestamp = '2025-01-01T14:00:05.000Z'; const mockDate = '2025-01-01'; -const mockValidClientsId = mockId.clients.valid[0]; -const mockValidUsersId = mockId.users.valid[0]; +const mockValidClientsId = mockId.clients.valid[0] as ClientsId; +const mockValidUsersId = mockId.users.valid[0] as UsersId; const expectExceptionResult = DBQueryErrorException; const mockBoolean = false; @@ -233,8 +235,8 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Throw DBQueryErrorException by catch-block', async () => { const mockParam_dto: RateLimitsUpdateDTO = { - client_id: mockId.clients.invalid[0], - user_id: mockId.users.invalid[0], + client_id: mockValidClientsId, + user_id: mockValidUsersId, day: '2025-01-01', last_modified: mockTimestamp }; diff --git a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts index e8465c8..85332fa 100644 --- a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts @@ -9,6 +9,8 @@ import ticketsRepository from "../../../src/repositories/tickets.repository"; import { TicketsFilterDTO, TicketsIntervalDTO, TicketsResponseDTO, TicketsResponseExtendedDTO, TicketsUpdateDTO } from "../../../src/dtos/tickets.dto"; import { TicketOption } from "../../../src/utils/enums/ticket-option.enum"; import { DeviceOption } from "../../../src/utils/enums/device-option.enum"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.mock("../../../src/configs/db", () => { return { @@ -19,10 +21,12 @@ jest.mock("../../../src/configs/db", () => { }); const mockTimestamp = '2025-01-01T14:00:04.000Z'; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockData: Tickets = { ticket_id: mockId.tickets.valid[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'test-title', @@ -109,8 +113,8 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { const mockData_entry0: TicketsResponseDTO = structuredClone(mockData); const mockData_entry1: TicketsResponseDTO = { ticket_id: 'another_valid_tickets_test_id', - client_id: 'another_valid_clients_test_id', - user_id: 'another_valid_users_test_id', + client_id: 'another_valid_clients_test_id' as ClientsId, + user_id: 'another_valid_users_test_id' as UsersId, status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, title: 'another-test-title', @@ -170,26 +174,22 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { let sql: string; let mockTimestamp_now: Date; - let mockClientId: string; - let mockUserId: string; beforeEach(() => { sql = `SELECT`; mockTimestamp_now = new Date('2025-01-01T14:01:50.000Z'); - mockClientId = mockId.clients.valid[0]; - mockUserId = mockId.users.valid[0]; }); test('Return data for existing entry, params: ', async () => { const mockParam_dto: TicketsIntervalDTO = { - client_id: mockClientId, + client_id: mockValidClientId, intervalTime: '1 minute' }; const mockResult: TicketsResponseDTO[] = [ mockData, { ticket_id: 'another_valid_tickets_id', - client_id: mockClientId, - user_id: 'another_valid_users_id', + client_id: mockValidClientId, + user_id: 'another_valid_users_id' as UsersId, status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, title: 'valid-test-title', @@ -211,21 +211,21 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { expect(DBConnection.getInstance).toHaveBeenCalled(); expect(mockClient.query).toHaveBeenCalledWith( expect.stringContaining(sql), - expect.arrayContaining([mockClientId, mockTimestamp_now, mockParam_dto.intervalTime]) + expect.arrayContaining([mockValidClientId, mockTimestamp_now, mockParam_dto.intervalTime]) ); }) test('Return data for existing entry, params: ', async () => { const mockParam_dto: TicketsIntervalDTO = { - user_id: mockUserId, + user_id: mockValidUserId, intervalTime: '1 minute' }; const mockResult: TicketsResponseDTO[] = [ mockData, { ticket_id: 'another_valid_tickets_id', - client_id: 'another_valid_client_id', - user_id: mockUserId, + client_id: 'another_valid_client_id' as ClientsId, + user_id: mockValidUserId, status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, title: 'valid-test-title', @@ -247,13 +247,13 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { expect(DBConnection.getInstance).toHaveBeenCalled(); expect(mockClient.query).toHaveBeenCalledWith( expect.stringContaining(sql), - expect.arrayContaining([mockUserId, mockTimestamp_now, mockParam_dto.intervalTime]) + expect.arrayContaining([mockValidUserId, mockTimestamp_now, mockParam_dto.intervalTime]) ); }) test('Return null for existing entry beyond time interval', async () => { const mockParam_dto: TicketsIntervalDTO = { - client_id: mockClientId, + client_id: mockValidClientId, intervalTime: '1 minute' }; const mockResult = null; @@ -266,7 +266,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { expect(DBConnection.getInstance).toHaveBeenCalled(); expect(mockClient.query).toHaveBeenCalledWith( expect.stringContaining(sql), - expect.arrayContaining([mockClientId, mockTimestamp_now, mockParam_dto.intervalTime]) + expect.arrayContaining([mockValidClientId, mockTimestamp_now, mockParam_dto.intervalTime]) ); }) }) @@ -275,7 +275,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { test('Throw DBQueryErrorException by catch-block', async () => { const mockParam_dto = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, intervalTime: '1 minute' }; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; @@ -317,7 +317,9 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { }) test('Return null for non-existing entry, params: non-existing ', async () => { - const mockParam_dto = { user_id: ['non-existing_users_test_id_0', 'non-existing_users_test_id_1'] }; + const mockParam_dto = { + user_id: ['non-existing_users_test_id_0', 'non-existing_users_test_id_1'] as UsersId[] + }; const mockValues = mockParam_dto.user_id; const mockResult: Tickets[] | null = null; @@ -352,8 +354,8 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { const mockParam_entity: Tickets = { ticket_id: mockId.tickets.new[0], - client_id: mockId.clients.valid[0], - user_id: mockId.users.valid[0], + client_id: mockValidClientId, + user_id: mockValidUserId, status: TicketStatus.ISSUED, option: TicketOption.SUPPORT, title: 'new-test-title', diff --git a/backend/tests/unit-tests/repositories/users.repositories.test.ts b/backend/tests/unit-tests/repositories/users.repositories.test.ts index e14665a..e5b9385 100644 --- a/backend/tests/unit-tests/repositories/users.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/users.repositories.test.ts @@ -2,7 +2,7 @@ import { DBConnection } from "../../../src/configs/db"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as MockUtils from "../../common.test-utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; -import { Users } from "../../../src/repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import { UserStatus } from "../../../src/utils/enums/user-status.enum"; import usersRepository from "../../../src/repositories/users.repository"; import { UsersUpdateDTO, UsersFlagUpdateDTO } from "../../../src/dtos/users.dto"; @@ -17,9 +17,10 @@ jest.mock("../../../src/configs/db", () => { } }); +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-03T14:00:03.000Z'; const mockData: Users = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'user@test.com', status: UserStatus.ACTIVE, flag: null, @@ -41,7 +42,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { }); test('Return data for existing entry, params: valid ', async () => { - const mockParam_id = mockId.users.valid[0]; + const mockParam_id = mockValidUserId; const mockResult: Users | null = structuredClone(mockData); const mockClient = MockUtils.mapMockDbClient(mockResult); @@ -56,7 +57,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { }) test('Return null for non-existing entry, params: non-existing ', async () => { - const mockParam_id = mockId.users.invalid[0]; + const mockParam_id = mockId.users.invalid[0] as UsersId; const mockResult: Users | null = null; const mockClient = MockUtils.mapMockDbClient(mockResult); @@ -74,7 +75,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.users.invalid[0]; + const mockParam_id = mockId.users.invalid[0] as UsersId; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; const mockResult: Users | null = null; @@ -152,7 +153,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { test('Return data for multiple existing entries', async () => { const mockData_entry0 = structuredClone(mockData); const mockData_entry1 = structuredClone(mockData_entry0); - mockData_entry1['user_id'] = 'another_valid_users_test_id'; + mockData_entry1['user_id'] = 'another_valid_users_test_id' as UsersId; mockData_entry1['email'] = 'user1@test.com'; const mockResult: Users[] = [mockData_entry0, mockData_entry1]; @@ -258,7 +259,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { beforeEach(() => { sql = 'INSERT'; mockParam_entity = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'new-user@test.com', status: UserStatus.ACTIVE, flag: null, @@ -315,7 +316,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.users.valid[0]; + const mockParam_id = mockValidUserId; const mockValues = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); const mockResult: Users = structuredClone(mockData); @@ -333,7 +334,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { }) test('Return null for no entries, params: non-existing ', async () => { - const mockParam_id = mockId.users.invalid[0]; + const mockParam_id = mockId.users.invalid[0] as UsersId; const mockValues = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); @@ -354,7 +355,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.users.invalid[0]; + const mockParam_id = mockId.users.invalid[0] as UsersId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -381,7 +382,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.users.valid[0]; + const mockParam_id = mockValidUserId; const mockValues = [mockParam_dto.flag, mockParam_dto.last_modified, mockParam_id]; const mockResult: Users = structuredClone(mockData); @@ -399,7 +400,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { }) test('Return null for no entries, params: non-existing ', async () => { - const mockParam_id = mockId.users.invalid[0]; + const mockParam_id = mockId.users.invalid[0] as UsersId; const mockValues = [mockParam_dto.flag, mockParam_dto.last_modified, mockParam_id]; const mockResult = null; @@ -419,7 +420,7 @@ describe('Unit-tests (repository), priority: entity Users', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.users.invalid[0]; + const mockParam_id = mockId.users.invalid[0] as UsersId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); diff --git a/backend/tests/unit-tests/utils/common.utils.test.ts b/backend/tests/unit-tests/utils/common.utils.test.ts index d8c197b..07a5b5e 100644 --- a/backend/tests/unit-tests/utils/common.utils.test.ts +++ b/backend/tests/unit-tests/utils/common.utils.test.ts @@ -1,4 +1,4 @@ -import { Users } from "../../../src/repositories/interfaces/users.entity.interface"; +import { Users, UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import { Flag } from "../../../src/utils/enums/flag.enum"; import { TicketStatus } from "../../../src/utils/enums/ticket-status.enum"; @@ -9,6 +9,8 @@ import { default as mockId } from "../../mock-data/id.mock-data.json"; describe('Unit-tests (utils), priority: synonym CommonUtils', () => { + const mockValidUserId = mockId.users.valid[0] as UsersId; + describe('Testing valid fn calls', () => { test('Fn mapKeyToHash()', () => { @@ -54,7 +56,7 @@ describe('Unit-tests (utils), priority: synonym CommonUtils', () => { test('Fn mapObjTimestamps()', () => { const mockParam_data: Users = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'user@test.com', status: UserStatus.ACTIVE, flag: null, @@ -65,7 +67,7 @@ describe('Unit-tests (utils), priority: synonym CommonUtils', () => { const testFn = CommonUtils.mapObjTimestamps(mockParam_data, mockParam_timeMapTargets); const expectResult: Users = { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'user@test.com', status: UserStatus.ACTIVE, flag: null, @@ -79,7 +81,7 @@ describe('Unit-tests (utils), priority: synonym CommonUtils', () => { test('Fn mapArrayTimestamps()', () => { const mockParam_data: Users[] = [ { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'user0@test.com', status: UserStatus.ACTIVE, flag: null, @@ -87,7 +89,7 @@ describe('Unit-tests (utils), priority: synonym CommonUtils', () => { created_on: '2025-01-01 14:00:03.000+01' }, { - user_id: mockId.users.new[0], + user_id: mockId.users.new[0] as UsersId, email: 'user1@test.com', status: UserStatus.ACTIVE, flag: null, @@ -100,7 +102,7 @@ describe('Unit-tests (utils), priority: synonym CommonUtils', () => { const testFn = CommonUtils.mapArrayTimestamps(mockParam_data, mockParam_timeMapTargets); const expectResult: Users[] = [ { - user_id: mockId.users.valid[0], + user_id: mockValidUserId, email: 'user0@test.com', status: UserStatus.ACTIVE, flag: null, @@ -108,7 +110,7 @@ describe('Unit-tests (utils), priority: synonym CommonUtils', () => { created_on: '2025-01-01T13:00:03.000Z' }, { - user_id: mockId.users.new[0], + user_id: mockId.users.new[0] as UsersId, email: 'user1@test.com', status: UserStatus.ACTIVE, flag: null, From 6a7b52eeaf5479ba157bf8d5ab4d187f372b5224 Mon Sep 17 00:00:00 2001 From: yqni13 Date: Mon, 9 Mar 2026 15:14:24 +0100 Subject: [PATCH 2/7] refactor: nominal type meta/tickets id --- backend/src/controllers/meta.controller.ts | 7 ++-- backend/src/controllers/tickets.controller.ts | 7 ++-- backend/src/dtos/meta.dto.ts | 5 +-- backend/src/dtos/tickets.dto.ts | 5 +-- .../container/penalty.container.middleware.ts | 2 +- .../penalties.interface.middleware.ts | 9 +++-- backend/src/middleware/observe.middleware.ts | 4 ++- backend/src/models/tickets.model.ts | 4 +-- .../interfaces/meta.entity.interface.ts | 6 ++-- .../interfaces/tickets.entity.interface.ts | 4 ++- backend/src/repositories/meta.repository.ts | 8 ++--- .../src/repositories/tickets.repository.ts | 8 ++--- backend/src/services/meta.service.ts | 7 ++-- backend/src/services/test.service.ts | 3 +- backend/src/services/tickets.service.ts | 7 ++-- .../middleware/penalty.integration.test.ts | 17 ++++++---- .../repositories/meta.integration.test.ts | 14 ++++---- .../repositories/tickets.integration.test.ts | 34 ++++++++++--------- backend/tests/mock-data/id.mock-data.json | 4 +-- .../middleware/maintenance.middleware.test.ts | 7 ++-- .../unit-tests/models/tickets.model.test.ts | 15 ++++---- .../repositories/meta.repositories.test.ts | 27 ++++++++------- .../repositories/tickets.repositories.test.ts | 29 ++++++++-------- 23 files changed, 131 insertions(+), 102 deletions(-) diff --git a/backend/src/controllers/meta.controller.ts b/backend/src/controllers/meta.controller.ts index 6ee6b94..a44a984 100644 --- a/backend/src/controllers/meta.controller.ts +++ b/backend/src/controllers/meta.controller.ts @@ -2,12 +2,13 @@ import { NextFunction, Request, Response } from "express"; import metaService from "../services/meta.service"; import { checkValidation } from "../middleware/validation.middleware"; import { MaintenanceResponseDTO, MaintenanceUpdateDTO, MetaResponseDTO, MetaUpdateDTO } from "../dtos/meta.dto"; +import { MetaId } from "../repositories/interfaces/meta.entity.interface"; class MetaController { async getMetaById(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id: number = +(req.params.id); + const id = (+req.params.id) as MetaId; const response: MetaResponseDTO | null = await metaService.getMetaById(id); res.json(response); } catch(err: any) { @@ -38,7 +39,7 @@ class MetaController { async patchMeta(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id: number = +(req.params.id); + const id = (+req.params.id) as MetaId; const dto: MetaUpdateDTO = req.body; const response: MetaResponseDTO | null = await metaService.updateMeta(id, dto); res.json(response); @@ -61,7 +62,7 @@ class MetaController { async patchMaintenanceMode(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id: number = +req.params.id; + const id = (+req.params.id) as MetaId; const dto: MaintenanceUpdateDTO = req.body; const response: MaintenanceResponseDTO | null = await metaService.updateMaintenanceMode(id, dto); res.json(response); diff --git a/backend/src/controllers/tickets.controller.ts b/backend/src/controllers/tickets.controller.ts index 602ff7e..8f34398 100644 --- a/backend/src/controllers/tickets.controller.ts +++ b/backend/src/controllers/tickets.controller.ts @@ -8,12 +8,13 @@ import { TicketsUpdateDTO } from "../dtos/tickets.dto"; import ticketsService from "../services/tickets.service"; +import { TicketsId } from "../repositories/interfaces/tickets.entity.interface"; class TicketsController { async getTicket(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id = req.params.id; + const id = req.params.id as TicketsId; const response: TicketsResponseExtendedDTO | null = await ticketsService.getTicketById(id); res.json(response); } catch(err: any) { @@ -65,7 +66,7 @@ class TicketsController { async patchTicket(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id: string = req.params.id; + const id = req.params.id as TicketsId; const dto: TicketsUpdateDTO = req.body; const response: TicketsResponseDTO | null = await ticketsService.updateTicket(id, dto); res.json(response); @@ -77,7 +78,7 @@ class TicketsController { async deleteTicket(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id = req.params.id; + const id = req.params.id as TicketsId; const response: boolean = await ticketsService.deleteTicket(id); res.json(response); } catch(err: any) { diff --git a/backend/src/dtos/meta.dto.ts b/backend/src/dtos/meta.dto.ts index 4fc3b44..3216424 100644 --- a/backend/src/dtos/meta.dto.ts +++ b/backend/src/dtos/meta.dto.ts @@ -1,3 +1,4 @@ +import { MetaId } from "../repositories/interfaces/meta.entity.interface" import { EnvMode } from "../utils/enums/env-mode.enum" import { MaintenanceMode } from "../utils/enums/maintenance-mode.enum" @@ -15,7 +16,7 @@ export interface MetaUpdateDTO { } export interface MetaResponseDTO { - id: number, + id: MetaId, app: string, author: string, build_on: string, @@ -36,7 +37,7 @@ export interface MaintenanceUpdateDTO { } export interface MaintenanceResponseDTO { - id: number, + id: MetaId, app: string, build_on: string, maintenance_mode: MaintenanceMode, diff --git a/backend/src/dtos/tickets.dto.ts b/backend/src/dtos/tickets.dto.ts index d7d900a..642c190 100644 --- a/backend/src/dtos/tickets.dto.ts +++ b/backend/src/dtos/tickets.dto.ts @@ -1,4 +1,5 @@ import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; +import { TicketsId } from "../repositories/interfaces/tickets.entity.interface"; import { UsersId } from "../repositories/interfaces/users.entity.interface"; import { SingleOrArray } from "../utils/custom-types.utils"; import { DeviceOption } from "../utils/enums/device-option.enum"; @@ -62,7 +63,7 @@ export interface TicketsFilterDTO { } export interface TicketsResponseDTO { - ticket_id: string, + ticket_id: TicketsId, client_id: ClientsId, user_id: UsersId, status: TicketStatus, @@ -79,7 +80,7 @@ export interface TicketsResponseDTO { } export interface TicketsResponseExtendedDTO { - ticket_id: string, + ticket_id: TicketsId, client_id: ClientsId, client_name: string, user_id: UsersId, diff --git a/backend/src/middleware/container/penalty.container.middleware.ts b/backend/src/middleware/container/penalty.container.middleware.ts index 036371d..b0ee529 100644 --- a/backend/src/middleware/container/penalty.container.middleware.ts +++ b/backend/src/middleware/container/penalty.container.middleware.ts @@ -11,6 +11,6 @@ export const penaltyHandler = new PenaltyHandler( new Map([ [Violation.CLIENTSFLAG, new ClientsFlagPenalty()], [Violation.USERSFLAG, new UsersFlagPenalty()], - [Violation.MAINTENANCE_TRAFFIC, new MaintenanceTrafficPenalty] + [Violation.MAINTENANCE_TRAFFIC, new MaintenanceTrafficPenalty()] ]) ) \ No newline at end of file diff --git a/backend/src/middleware/interfaces/penalties.interface.middleware.ts b/backend/src/middleware/interfaces/penalties.interface.middleware.ts index 76a1395..5f68f5f 100644 --- a/backend/src/middleware/interfaces/penalties.interface.middleware.ts +++ b/backend/src/middleware/interfaces/penalties.interface.middleware.ts @@ -1,3 +1,6 @@ +import { ClientsId } from "../../repositories/interfaces/clients.entity.interface"; +import { MetaId } from "../../repositories/interfaces/meta.entity.interface"; +import { UsersId } from "../../repositories/interfaces/users.entity.interface"; import { Flag } from "../../utils/enums/flag.enum"; import { MaintenanceMode } from "../../utils/enums/maintenance-mode.enum"; import { Violation } from "../../utils/enums/violations.enum"; @@ -20,18 +23,18 @@ export type PenaltyContext = export interface PenaltyClientsFlagContext extends BasePenaltyContext { type: Violation.CLIENTSFLAG, - id: string, + id: ClientsId, penaltyValue: Flag | null } export interface PenaltyUsersFlagContext extends BasePenaltyContext { type: Violation.USERSFLAG, - id: string, + id: UsersId, penaltyValue: Flag | null } export interface PenaltyMaintenanceTrafficContext extends BasePenaltyContext { type: Violation.MAINTENANCE_TRAFFIC, - id: number, + id: MetaId, penaltyValue: MaintenanceMode } \ No newline at end of file diff --git a/backend/src/middleware/observe.middleware.ts b/backend/src/middleware/observe.middleware.ts index b7e2ee1..6e056ba 100644 --- a/backend/src/middleware/observe.middleware.ts +++ b/backend/src/middleware/observe.middleware.ts @@ -14,6 +14,8 @@ import { RateLimitsData, RateLimitsResponse } from "./interfaces/rate-limits.int import { ExceedMaxEndpointException } from "../utils/exceptions/api.exception"; import { DemoLimitsIncrement, RateLimitsIncrement } from "./adapter/rate-limits.adapter.middleware"; import { penaltyHandler } from "./container/penalty.container.middleware"; +import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../repositories/interfaces/users.entity.interface"; export function observe(isDemo: boolean = false) { return async function (req: Request, res: Response, next: NextFunction) { @@ -63,5 +65,5 @@ async function checkDemoLimits(): Promise { ], new DemoLimitsIncrement() ); - return await engine.process({ client_id: 'demo', user_id: 'demo'}); + return await engine.process({ client_id: 'demo' as ClientsId, user_id: 'demo' as UsersId}); } \ No newline at end of file diff --git a/backend/src/models/tickets.model.ts b/backend/src/models/tickets.model.ts index 2a908f9..c014758 100644 --- a/backend/src/models/tickets.model.ts +++ b/backend/src/models/tickets.model.ts @@ -1,5 +1,5 @@ import { TicketsCreateDTO, TicketsResponseDTO, TicketsUpdateDTO } from "../dtos/tickets.dto"; -import { Tickets } from "../repositories/interfaces/tickets.entity.interface"; +import { Tickets, TicketsId } from "../repositories/interfaces/tickets.entity.interface"; import * as CommonUtils from "../utils/common.utils"; import { TicketStatus } from "../utils/enums/ticket-status.enum"; import { FilesService } from "../services/files.service"; @@ -8,7 +8,7 @@ import { PermissionException } from "../utils/exceptions/auth.exception"; class TicketsModel { async generateTicket(dto: TicketsCreateDTO, files: Express.Multer.File[] | null): Promise { const timestamp = CommonUtils.getTimestampUTC(); - const newId = CommonUtils.generateUUID(); + const newId = CommonUtils.generateUUID() as TicketsId; let paths: string[] | null = null; if(files) { const filesService = new FilesService(files, 'tickets'); diff --git a/backend/src/repositories/interfaces/meta.entity.interface.ts b/backend/src/repositories/interfaces/meta.entity.interface.ts index 51c56d4..0a5b7bc 100644 --- a/backend/src/repositories/interfaces/meta.entity.interface.ts +++ b/backend/src/repositories/interfaces/meta.entity.interface.ts @@ -1,8 +1,10 @@ import { EnvMode } from "../../utils/enums/env-mode.enum"; import { MaintenanceMode } from "../../utils/enums/maintenance-mode.enum"; +export type MetaId = number & { readonly brand: unique symbol }; + export interface Meta { - id: number, + id: MetaId, app: string, author: string, build_on: string, @@ -18,7 +20,7 @@ export interface Meta { } export interface Maintenance { - id: number, + id: MetaId, app: string, build_on: string, maintenance_mode: MaintenanceMode, diff --git a/backend/src/repositories/interfaces/tickets.entity.interface.ts b/backend/src/repositories/interfaces/tickets.entity.interface.ts index 99b8c71..4a482fe 100644 --- a/backend/src/repositories/interfaces/tickets.entity.interface.ts +++ b/backend/src/repositories/interfaces/tickets.entity.interface.ts @@ -5,8 +5,10 @@ import { TicketStatus } from "../../utils/enums/ticket-status.enum"; import { ClientsId } from "./clients.entity.interface"; import { UsersId } from "./users.entity.interface"; +export type TicketsId = string & { readonly brand: unique symbol }; + export interface Tickets { - ticket_id: string, + ticket_id: TicketsId, client_id: ClientsId, user_id: UsersId, status: TicketStatus, diff --git a/backend/src/repositories/meta.repository.ts b/backend/src/repositories/meta.repository.ts index 0689e63..1d99bad 100644 --- a/backend/src/repositories/meta.repository.ts +++ b/backend/src/repositories/meta.repository.ts @@ -1,7 +1,7 @@ import { QueryResult } from "pg"; import { DBConnection } from "../configs/db"; import { IBaseRepository, IFindRepository } from "./interfaces/base.repository.interface"; -import { Maintenance, Meta } from "./interfaces/meta.entity.interface"; +import { Maintenance, Meta, MetaId } from "./interfaces/meta.entity.interface"; import { logError } from "../utils/common.utils"; import { DBQueryErrorException } from "../utils/exceptions/db.exception"; @@ -13,7 +13,7 @@ class MetaRepository implements IBaseRepository, IFindRepository { this.table = "meta"; } - async findById(id: number): Promise { + async findById(id: MetaId): Promise { const filterColumn = "id"; const sql = `SELECT id, app, author, build_on, environment, app_version, db_version, docker_image, docker_version, jenkins_version, maintenance_mode, last_modified, created_on @@ -103,7 +103,7 @@ class MetaRepository implements IBaseRepository, IFindRepository { } } - async update(id: number, dto: Partial): Promise { + async update(id: MetaId, dto: Partial): Promise { const filterColumn = "id"; const sql = `UPDATE ${this.table} SET app = $1, author = $2, build_on = $3, environment = $4, app_version = $5, db_version = $6, @@ -129,7 +129,7 @@ class MetaRepository implements IBaseRepository, IFindRepository { } } - async updateMaintenance(id: number, dto: Partial): Promise { + async updateMaintenance(id: MetaId, dto: Partial): Promise { const filterColumn = "id"; const sql = `UPDATE ${this.table} SET maintenance_mode = $1, last_modified = $2 diff --git a/backend/src/repositories/tickets.repository.ts b/backend/src/repositories/tickets.repository.ts index 79046f3..d9a5a9d 100644 --- a/backend/src/repositories/tickets.repository.ts +++ b/backend/src/repositories/tickets.repository.ts @@ -6,7 +6,7 @@ import { } from "./interfaces/base.repository.interface"; import { DBConnection } from "../configs/db"; import { QueryResult } from "pg"; -import { Tickets } from "./interfaces/tickets.entity.interface"; +import { Tickets, TicketsId } from "./interfaces/tickets.entity.interface"; import { TicketsFilterDTO, TicketsIntervalDTO, TicketsResponseExtendedDTO } from "../dtos/tickets.dto"; import { DBQueryErrorException } from "../utils/exceptions/db.exception"; import { logError, now } from "../utils/common.utils"; @@ -24,7 +24,7 @@ IDeleteRepository this.table = "tickets"; } - async findById(id: string): Promise { + async findById(id: TicketsId): Promise { const filterColumn = "ticket_id"; const sql = `SELECT ${this.table}.*, @@ -138,7 +138,7 @@ IDeleteRepository } } - async update(id: string, dto: Partial): Promise { + async update(id: TicketsId, dto: Partial): Promise { const filterColumn = "ticket_id"; const sql = `UPDATE ${this.table} SET status = $1, title = $2, message = $3, option = $4, flag = $5, info_browser = $6, info_os = $7, @@ -163,7 +163,7 @@ IDeleteRepository } } - async delete(id: string): Promise { + async delete(id: TicketsId): Promise { const filterColumn = "ticket_id"; const sql = `DELETE FROM ${this.table} WHERE ${filterColumn} = $1;`; const value = [id]; diff --git a/backend/src/services/meta.service.ts b/backend/src/services/meta.service.ts index 1fa3f47..9020b6a 100644 --- a/backend/src/services/meta.service.ts +++ b/backend/src/services/meta.service.ts @@ -4,6 +4,7 @@ import { MetaUpdateDTO, MaintenanceUpdateDTO, } from '../dtos/meta.dto'; +import { MetaId } from '../repositories/interfaces/meta.entity.interface'; import metaRepository from '../repositories/meta.repository'; import * as CommonUtils from "../utils/common.utils"; @@ -14,7 +15,7 @@ class MetaService { this.timeMapTargets = ['build_on', 'created_on', 'last_modified']; } - async getMetaById(id: number): Promise { + async getMetaById(id: MetaId): Promise { const result = await metaRepository.findById(id); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } @@ -29,7 +30,7 @@ class MetaService { return !result ? null : CommonUtils.mapArrayTimestamps(result, this.timeMapTargets); } - async updateMeta(id: number, dto: MetaUpdateDTO): Promise { + async updateMeta(id: MetaId, dto: MetaUpdateDTO): Promise { dto.last_modified = CommonUtils.getTimestampUTC(); const result = await metaRepository.update(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); @@ -40,7 +41,7 @@ class MetaService { return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async updateMaintenanceMode(id: number, dto: MaintenanceUpdateDTO): Promise { + async updateMaintenanceMode(id: MetaId, dto: MaintenanceUpdateDTO): Promise { dto.last_modified = CommonUtils.getTimestampUTC(); const result = await metaRepository.updateMaintenance(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); diff --git a/backend/src/services/test.service.ts b/backend/src/services/test.service.ts index bc86aac..de23863 100644 --- a/backend/src/services/test.service.ts +++ b/backend/src/services/test.service.ts @@ -1,6 +1,7 @@ import { MetaResponseDTO } from '../dtos/meta.dto'; import { TestDemoDTO, TestErrorDTO } from '../dtos/test.dto'; import testModel from '../models/test.model'; +import { MetaId } from '../repositories/interfaces/meta.entity.interface'; import metaRepository from '../repositories/meta.repository'; import { DemoMode } from '../utils/enums/demo-mode.enum'; @@ -13,7 +14,7 @@ class TestService { let result: any; switch(dto.demo_mode) { case(DemoMode.SUCCESS): { - result = await metaRepository.findById(1); + result = await metaRepository.findById(1 as MetaId); result = { app_version: (result as MetaResponseDTO).app_version }; break; } diff --git a/backend/src/services/tickets.service.ts b/backend/src/services/tickets.service.ts index f63e56f..8e5601a 100644 --- a/backend/src/services/tickets.service.ts +++ b/backend/src/services/tickets.service.ts @@ -7,6 +7,7 @@ import { TicketsIntervalDTO } from "../dtos/tickets.dto"; import ticketsModel from "../models/tickets.model"; +import { TicketsId } from "../repositories/interfaces/tickets.entity.interface"; import ticketsRepository from "../repositories/tickets.repository"; import * as CommonUtils from "../utils/common.utils"; @@ -17,7 +18,7 @@ class TicketsService { this.timeMapTargets = ['last_modified', 'created_on']; } - async getTicketById(id: string): Promise { + async getTicketById(id: TicketsId): Promise { const result = await ticketsRepository.findById(id); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } @@ -43,13 +44,13 @@ class TicketsService { return CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async updateTicket(id: string, dto: TicketsUpdateDTO): Promise { + async updateTicket(id: TicketsId, dto: TicketsUpdateDTO): Promise { dto = ticketsModel.mapTicketUpdateDto(dto); const result = await ticketsRepository.update(id, dto); return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async deleteTicket(id: string): Promise { + async deleteTicket(id: TicketsId): Promise { const ticket = await ticketsRepository.findById(id); if(ticket && ticketsModel.isPermittedToDelete(ticket)) { await ticketsModel.handleTicketBeforeDelete(ticket); diff --git a/backend/tests/integration-tests/middleware/penalty.integration.test.ts b/backend/tests/integration-tests/middleware/penalty.integration.test.ts index 7b9fc1b..16033ec 100644 --- a/backend/tests/integration-tests/middleware/penalty.integration.test.ts +++ b/backend/tests/integration-tests/middleware/penalty.integration.test.ts @@ -10,12 +10,15 @@ import { runMigrations } from "../../db-migrations.setup"; import usersService from "../../../src/services/users.service"; import { MaintenanceMode } from "../../../src/utils/enums/maintenance-mode.enum"; import metaService from "../../../src/services/meta.service"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { MetaId } from "../../../src/repositories/interfaces/meta.entity.interface"; jest.setTimeout(60000); -const testValidMetaId = mockId.meta.valid[0]; -const testValidClientsId = mockId.clients.valid[0]; -const testValidUsersId = mockId.users.valid[0]; +const testValidMetaId = mockId.meta.valid[0] as MetaId; +const testValidClientId = mockId.clients.valid[0] as ClientsId; +const testValidUserId = mockId.users.valid[0] as UsersId; describe('Integration-tests (middleware), priority: class PenaltyHandler', () => { @@ -40,13 +43,13 @@ describe('Integration-tests (middleware), priority: class PenaltyHandler', () => test('Params: has Violation.CLIENTSFLAG, result: "PENALTY"', async () => { const testParam_context: PenaltyContext = { type: Violation.CLIENTSFLAG, - id: testValidClientsId, + id: testValidClientId, penaltyValue: null }; await dbTestSetup.addTestData(); const _ = await penaltyHandler.apply(testParam_context); - const testFn = await clientsService.getClientById(testValidClientsId); + const testFn = await clientsService.getClientById(testValidClientId); const testClientsFlagResult = Flag.WARNING; expect(testFn?.flag).toBe(testClientsFlagResult); @@ -55,13 +58,13 @@ describe('Integration-tests (middleware), priority: class PenaltyHandler', () => test('Params: has Violation.USERSFLAG, result: "PENALTY"', async () => { const testParam_context: PenaltyContext = { type: Violation.USERSFLAG, - id: testValidUsersId, + id: testValidUserId, penaltyValue: null }; await dbTestSetup.addTestData(); const _ = await penaltyHandler.apply(testParam_context); - const testFn = await usersService.getUserById(testValidUsersId); + const testFn = await usersService.getUserById(testValidUserId); const testUsersFlagResult = Flag.WARNING; expect(testFn?.flag).toBe(testUsersFlagResult); diff --git a/backend/tests/integration-tests/repositories/meta.integration.test.ts b/backend/tests/integration-tests/repositories/meta.integration.test.ts index a845d29..27d2924 100644 --- a/backend/tests/integration-tests/repositories/meta.integration.test.ts +++ b/backend/tests/integration-tests/repositories/meta.integration.test.ts @@ -1,4 +1,4 @@ -import { Maintenance, Meta } from './../../../src/repositories/interfaces/meta.entity.interface'; +import { Maintenance, Meta, MetaId } from './../../../src/repositories/interfaces/meta.entity.interface'; import { NextFunction, Request, Response } from "express"; import { DBTestSetup } from "../../db-container.setup"; import { runMigrations } from '../../db-migrations.setup'; @@ -23,6 +23,8 @@ jest.mock('../../../src/middleware/maintenance.middleware', () => ({ import app from '../../../src/app'; jest.setTimeout(60000); + +const testValidMetaId = mockId.meta.valid[0] as MetaId; const testTimestamp = '2025-01-01T14:00:01.000Z'; describe('Integration-tests (repository), priority: entity Meta', () => { @@ -37,7 +39,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { await runMigrations('meta.integration.test.ts'); apiUrl = '/api/v1/meta'; mockResult = { - id: 1, + id: testValidMetaId, app: 'support', author: 'yqni13', build_on: testTimestamp, @@ -65,7 +67,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { }); test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = 1; + const testParam_id = testValidMetaId; const testResult: Meta = { id: testParam_id, app: "support", @@ -93,7 +95,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { test('Repository process fn findByName(), result: "SUCCESS"', async () => { const testParam_name = "support"; const testResult: Meta = { - id: 1, + id: testValidMetaId, app: testParam_name, author: "yqni13", build_on: testTimestamp, @@ -118,7 +120,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { test('Repository process fn findAll(), result: "SUCCESS"', async () => { const testResult: Meta[] = [{ - id: 1, + id: testValidMetaId, app: "support", author: "yqni13", build_on: testTimestamp, @@ -142,7 +144,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { }) test('Repository process fn update(), result: "SUCCESS"', async () => { - const testParam_id = 1; + const testParam_id = testValidMetaId; const testParam_dto: MetaUpdateDTO = { app: 'support', author: 'yqni13', diff --git a/backend/tests/integration-tests/repositories/tickets.integration.test.ts b/backend/tests/integration-tests/repositories/tickets.integration.test.ts index 7d806db..5e47c0a 100644 --- a/backend/tests/integration-tests/repositories/tickets.integration.test.ts +++ b/backend/tests/integration-tests/repositories/tickets.integration.test.ts @@ -21,7 +21,11 @@ import ticketsModel from "../../../src/models/tickets.model"; import { CloudService } from "../../../src/services/cloud.service"; import { TicketOption } from "../../../src/utils/enums/ticket-option.enum"; import { DeviceOption } from "../../../src/utils/enums/device-option.enum"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { TicketsId } from "../../../src/repositories/interfaces/tickets.entity.interface"; +const testValidTicketId = mockId.tickets.valid[0] as TicketsId; const testValidClientId = mockId.clients.valid[0] as ClientsId; const testValidUserId = mockId.users.valid[0] as UsersId; const testTimestamp = '2025-01-01T14:00:04.000Z'; @@ -49,8 +53,6 @@ jest.mock('../../../src/middleware/observe.middleware.ts', () => ({ })) import app from '../../../src/app'; -import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; -import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.setTimeout(60000); @@ -60,7 +62,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { let apiUrl: string; const testClientsName = 'TESTCLIENT'; const testUsersEmail = 'max.mustermann@yqni13.com'; - const testNewParam_ticket_id = mockId.tickets.new[0]; + const testNewParam_ticket_id = mockId.tickets.new[0] as TicketsId; beforeAll(async () => { dbTestSetup = new DBTestSetup(); await dbTestSetup.init(); @@ -81,7 +83,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { describe('Testing valid fn calls', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = mockId.tickets.valid[0]; + const testParam_id = testValidTicketId; const testResult: TicketsResponseExtendedDTO = { ticket_id: testParam_id, client_id: testValidClientId, @@ -112,7 +114,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Repository process fn findAll(), result: "SUCCESS"', async () => { const testResult: TicketsResponseDTO[] = [ { - ticket_id: mockId.tickets.valid[0], + ticket_id: testValidTicketId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -128,7 +130,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { created_on: testTimestamp }, { - ticket_id: mockId.tickets.valid[1], + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -174,7 +176,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }; const testResult: TicketsResponseDTO[] = [ { - ticket_id: mockId.tickets.valid[0], + ticket_id: testValidTicketId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -190,7 +192,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { created_on: testTimestamp }, { - ticket_id: mockId.tickets.valid[1], + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -223,7 +225,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }; const testResult: TicketsResponseDTO[] = [ { - ticket_id: mockId.tickets.valid[0], + ticket_id: testValidTicketId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -239,7 +241,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { created_on: testTimestamp }, { - ticket_id: mockId.tickets.valid[1], + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -272,7 +274,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }; const testResult: TicketsResponseDTO[] = [ { - ticket_id: mockId.tickets.valid[0], + ticket_id: testValidTicketId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -288,7 +290,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { created_on: testTimestamp }, { - ticket_id: mockId.tickets.valid[1], + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -321,7 +323,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }; const testResult: TicketsResponseDTO[] = [ { - ticket_id: mockId.tickets.valid[0], + ticket_id: testValidTicketId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -337,7 +339,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { created_on: testTimestamp }, { - ticket_id: mockId.tickets.valid[1], + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: testValidClientId, user_id: testValidUserId, status: TicketStatus.ISSUED, @@ -522,7 +524,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }) test('Repository process fn update() without resource_paths, result: "SUCCESS"', async () => { - const testParam_id = mockId.tickets.valid[1]; + const testParam_id = mockId.tickets.valid[1] as TicketsId; const testParam_dto: TicketsUpdateDTO = { status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, @@ -559,7 +561,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }) test('Repository process fn update() with resource_paths, result: "SUCCESS"', async () => { - const testParam_id = mockId.tickets.valid[0]; + const testParam_id = testValidTicketId; const testParam_dto: TicketsUpdateDTO = { status: TicketStatus.ACTIVE, option: TicketOption.BUG, diff --git a/backend/tests/mock-data/id.mock-data.json b/backend/tests/mock-data/id.mock-data.json index 8947320..5065d8d 100644 --- a/backend/tests/mock-data/id.mock-data.json +++ b/backend/tests/mock-data/id.mock-data.json @@ -40,12 +40,12 @@ 0 ], "new": [ - 3, + 2, 4 ], "valid": [ 1, - 2 + 3 ] }, "tickets": { diff --git a/backend/tests/unit-tests/middleware/maintenance.middleware.test.ts b/backend/tests/unit-tests/middleware/maintenance.middleware.test.ts index 29e2a5b..33cdb21 100644 --- a/backend/tests/unit-tests/middleware/maintenance.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/maintenance.middleware.test.ts @@ -6,9 +6,12 @@ import { MaintenanceMode } from '../../../src/utils/enums/maintenance-mode.enum' import { ErrorStatusCodes } from '../../../src/utils/errorStatusCodes.utils'; import { maintain, updateTrafficError } from '../../../src/middleware/maintenance.middleware'; import { default as mockId } from "../../mock-data/id.mock-data.json"; +import { MetaId } from '../../../src/repositories/interfaces/meta.entity.interface'; describe('Unit-tests (middleware), priority: synonym MaintenanceMiddleware', () => { + const mockValidMetaId = mockId.meta.valid[0] as MetaId; + describe('Middleware tests, priority: fn maintain()', () => { const mockTimestamp = '2025-01-01T14:00:00.000Z'; @@ -18,7 +21,7 @@ describe('Unit-tests (middleware), priority: synonym MaintenanceMiddleware', () let mockMaintenanceResponse: MaintenanceResponseDTO | null; beforeEach(() => { mockMaintenanceResponse = { - id: mockId.meta.valid[0], + id: mockValidMetaId, app: 'support', build_on: mockTimestamp, maintenance_mode: MaintenanceMode.A000, @@ -129,7 +132,7 @@ describe('Unit-tests (middleware), priority: synonym MaintenanceMiddleware', () let mockMaintenanceResponse: MaintenanceResponseDTO; beforeEach(() => { mockMaintenanceResponse = { - id: 1, + id: mockValidMetaId, app: 'support', build_on: mockTimestamp, maintenance_mode: MaintenanceMode.T011, diff --git a/backend/tests/unit-tests/models/tickets.model.test.ts b/backend/tests/unit-tests/models/tickets.model.test.ts index be44c41..8ea3ebc 100644 --- a/backend/tests/unit-tests/models/tickets.model.test.ts +++ b/backend/tests/unit-tests/models/tickets.model.test.ts @@ -2,7 +2,7 @@ import { TicketsCreateDTO, TicketsResponseDTO, TicketsUpdateDTO } from "../../.. import * as CommonUtils from "../../../src/utils/common.utils"; import * as mockId from "../../mock-data/id.mock-data.json"; import { TicketStatus } from "../../../src/utils/enums/ticket-status.enum"; -import { Tickets } from "../../../src/repositories/interfaces/tickets.entity.interface"; +import { Tickets, TicketsId } from "../../../src/repositories/interfaces/tickets.entity.interface"; import ticketsModel from "../../../src/models/tickets.model"; import { Readable } from 'stream'; import { FilesService } from "../../../src/services/files.service"; @@ -11,6 +11,7 @@ import { PermissionException } from "../../../src/utils/exceptions/auth.exceptio import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +const mockValidTicketId = mockId.tickets.valid[0] as TicketsId; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:04.000Z'; @@ -54,7 +55,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { describe('Testing valid fn calls', () => { test('Generate new object, priority: no files', async () => { - const mockParam_id = mockId.tickets.new[0]; + const mockParam_id = mockId.tickets.new[0] as TicketsId; const mockParam_dto: TicketsCreateDTO = { client_id: mockValidClientId, user_id: mockValidUserId, @@ -85,7 +86,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { }) test('Generate new object, priority: single file', async () => { - const mockParam_id = mockId.tickets.new[0]; + const mockParam_id = mockId.tickets.new[0] as TicketsId; const mockParam_dto: TicketsCreateDTO = { client_id: mockValidClientId, user_id: mockValidUserId, @@ -121,7 +122,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { }) test('Generate new object, priority: multiple files', async () => { - const mockParam_id = mockId.tickets.new[0]; + const mockParam_id = mockId.tickets.new[0] as TicketsId; const mockParam_dto: TicketsCreateDTO = { client_id: mockValidClientId, user_id: mockValidUserId, @@ -194,7 +195,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Check for file deletion => call FilesService.deleteFiles(), params: ', async () => { const mockParam_dto: TicketsResponseDTO = { - ticket_id: mockId.tickets.valid[0], + ticket_id: mockValidTicketId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -218,7 +219,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { test('Check for file deletion => does NOT call FilesService.deleteFiles(), params: ', async () => { const mockParam_dto: TicketsResponseDTO = { - ticket_id: mockId.tickets.valid[1], + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -243,7 +244,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { let mockParam_dto: TicketsResponseDTO; beforeEach(() => { mockParam_dto = { - ticket_id: mockId.tickets.valid[0], + ticket_id: mockValidTicketId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.CLOSED, diff --git a/backend/tests/unit-tests/repositories/meta.repositories.test.ts b/backend/tests/unit-tests/repositories/meta.repositories.test.ts index 372045b..574400d 100644 --- a/backend/tests/unit-tests/repositories/meta.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/meta.repositories.test.ts @@ -1,6 +1,6 @@ import { DBConnection } from "../../../src/configs/db"; import metaRepository from "../../../src/repositories/meta.repository"; -import { Maintenance, Meta } from "../../../src/repositories/interfaces/meta.entity.interface"; +import { Maintenance, Meta, MetaId } from "../../../src/repositories/interfaces/meta.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as MockUtils from "../../common.test-utils"; import { MaintenanceMode } from "../../../src/utils/enums/maintenance-mode.enum"; @@ -17,9 +17,10 @@ jest.mock("../../../src/configs/db", () => { } }); +const mockValidMetaId = mockId.meta.valid[0] as MetaId; const mockTimestamp = '2025-01-01T14:00:01.000Z'; const mockData: Meta = { - id: 1, + id: mockValidMetaId, app: "support", author: "yqni13", build_on: mockTimestamp, @@ -48,7 +49,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { }); test('Return data for existing entry, params: = 1', async () => { - const mockParam_id = mockId.meta.valid[0]; + const mockParam_id = mockValidMetaId; const mockResult: Meta = structuredClone(mockData); const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await metaRepository.findById(mockParam_id); @@ -62,7 +63,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { }) test('Return null for non-existing entry, params: = 0', async () => { - const mockParam_id = mockId.meta.invalid[0]; + const mockParam_id = mockId.meta.invalid[0] as MetaId; const mockResult = null; const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await metaRepository.findById(mockParam_id); @@ -79,7 +80,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.meta.invalid[0]; + const mockParam_id = mockId.meta.invalid[0] as MetaId; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -156,7 +157,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { test('Return data for multiple existing entries', async () => { const mockData_entry0 = structuredClone(mockData); const mockData_entry1 = structuredClone(mockData_entry0); - mockData_entry1['id'] = 2; + mockData_entry1['id'] = 2 as MetaId; mockData_entry1['app'] = 'valid_meta_test_name'; const mockResult: Meta[] = [mockData_entry0, mockData_entry1]; @@ -206,7 +207,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { }); test('Return data for existing entry, params: = "valid_meta_test_name"', async () => { - const mockParam_id = mockId.meta.valid[0];; + const mockParam_id = mockValidMetaId; const mockParam_name = 'valid_meta_test_name'; const mockResult: Maintenance = { id: mockParam_id, @@ -266,7 +267,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.meta.valid[0]; + const mockParam_id = mockValidMetaId; const mockValues: any[] = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); const mockResult: Meta = structuredClone(mockData); @@ -285,7 +286,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.meta.invalid[0]; + const mockParam_id = mockId.meta.invalid[0] as MetaId; const mockValues: any[] = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); @@ -307,7 +308,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.meta.invalid[0]; + const mockParam_id = mockId.meta.invalid[0] as MetaId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -336,7 +337,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.meta.valid[0]; + const mockParam_id = mockValidMetaId; mockValues = [mockParam_dto.maintenance_mode, mockParam_dto.last_modified, mockParam_id]; const mockResult: Maintenance = { id: mockData.id, @@ -362,7 +363,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.meta.invalid[0]; + const mockParam_id = mockId.meta.invalid[0] as MetaId; mockValues = [mockParam_dto.maintenance_mode, mockParam_dto.last_modified, mockParam_id]; const mockResult = null; @@ -382,7 +383,7 @@ describe('Unit-tests (repository), priority: entity Meta', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.meta.invalid[0]; + const mockParam_id = mockId.meta.invalid[0] as MetaId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); diff --git a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts index 85332fa..d1123b0 100644 --- a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts @@ -2,7 +2,7 @@ import { DBConnection } from "../../../src/configs/db"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as MockUtils from "../../common.test-utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; -import { Tickets } from "../../../src/repositories/interfaces/tickets.entity.interface"; +import { Tickets, TicketsId } from "../../../src/repositories/interfaces/tickets.entity.interface"; import { DBQueryErrorException } from "../../../src/utils/exceptions/db.exception"; import { TicketStatus } from "../../../src/utils/enums/ticket-status.enum"; import ticketsRepository from "../../../src/repositories/tickets.repository"; @@ -20,11 +20,12 @@ jest.mock("../../../src/configs/db", () => { } }); -const mockTimestamp = '2025-01-01T14:00:04.000Z'; +const mockValidTicketId = mockId.tickets.valid[0] as TicketsId; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; +const mockTimestamp = '2025-01-01T14:00:04.000Z'; const mockData: Tickets = { - ticket_id: mockId.tickets.valid[0], + ticket_id: mockValidTicketId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -71,7 +72,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { }) test('Return null for non-existing entry, params: non-existing ', async () => { - const mockParam_id = mockId.tickets.invalid[0]; + const mockParam_id = mockId.tickets.invalid[0] as TicketsId; const mockResult = null; const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await ticketsRepository.findById(mockParam_id); @@ -88,7 +89,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.tickets.invalid[0]; + const mockParam_id = mockId.tickets.invalid[0] as TicketsId; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -112,7 +113,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { test('Return data for multiple existing entries', async () => { const mockData_entry0: TicketsResponseDTO = structuredClone(mockData); const mockData_entry1: TicketsResponseDTO = { - ticket_id: 'another_valid_tickets_test_id', + ticket_id: 'another_valid_tickets_test_id' as TicketsId, client_id: 'another_valid_clients_test_id' as ClientsId, user_id: 'another_valid_users_test_id' as UsersId, status: TicketStatus.ACTIVE, @@ -187,7 +188,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { const mockResult: TicketsResponseDTO[] = [ mockData, { - ticket_id: 'another_valid_tickets_id', + ticket_id: 'another_valid_tickets_id' as TicketsId, client_id: mockValidClientId, user_id: 'another_valid_users_id' as UsersId, status: TicketStatus.ACTIVE, @@ -223,7 +224,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { const mockResult: TicketsResponseDTO[] = [ mockData, { - ticket_id: 'another_valid_tickets_id', + ticket_id: 'another_valid_tickets_id' as TicketsId, client_id: 'another_valid_client_id' as ClientsId, user_id: mockValidUserId, status: TicketStatus.ACTIVE, @@ -353,7 +354,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { describe('Database tests table , priority: fn create()', () => { const mockParam_entity: Tickets = { - ticket_id: mockId.tickets.new[0], + ticket_id: mockId.tickets.new[0] as TicketsId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -422,7 +423,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockData.user_id; + const mockParam_id = mockData.ticket_id; const mockValues: any[] = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); const mockResult: Tickets = structuredClone(mockData); @@ -440,7 +441,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.tickets.invalid[0]; + const mockParam_id = mockId.tickets.invalid[0] as TicketsId; const mockValues: any[] = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); @@ -460,7 +461,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.tickets.invalid[0]; + const mockParam_id = mockId.tickets.invalid[0] as TicketsId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -497,7 +498,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { }) test('Return false for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.tickets.invalid[0]; + const mockParam_id = mockId.tickets.invalid[0] as TicketsId; const mockResult = false; const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await ticketsRepository.delete(mockParam_id); @@ -514,7 +515,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.tickets.invalid[0]; + const mockParam_id = mockId.tickets.invalid[0] as TicketsId; const mockErrorMsg = "DB ERROR ON DELETE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); From 6f78c5fff47d7fdbb8aceae2fb51881a40e5faef Mon Sep 17 00:00:00 2001 From: yqni13 Date: Mon, 9 Mar 2026 15:15:09 +0100 Subject: [PATCH 3/7] refactor: nominal type rest of entities id --- .../controllers/feedback-rating.controller.ts | 3 +- .../src/controllers/feedback.controller.ts | 5 +- backend/src/dtos/demo-limits.dto.ts | 4 +- backend/src/dtos/feedback.dto.ts | 3 +- backend/src/dtos/rate-limits.dto.ts | 3 +- .../feedback-rating.repository.ts | 5 +- .../src/repositories/feedback.repository.ts | 6 +- .../demo-limits.entity.interface.ts | 4 +- .../interfaces/feedback.entity.interface.ts | 4 +- .../rate-limits.entity.interface.ts | 4 +- .../src/services/feedback-rating.service.ts | 11 +--- backend/src/services/feedback.service.ts | 7 +-- .../repositories/feedback.integration.test.ts | 19 +++--- .../rate-limits.integration.test.ts | 11 ++-- .../rate-limits.rules.middleware.test.ts | 10 ++-- .../unit-tests/models/feedback.model.test.ts | 7 ++- .../models/rate-limits.models.test.ts | 13 ++-- .../feedback-rating.repositories.test.ts | 8 +-- .../feedback.repositories.test.ts | 17 +++--- .../rate-limits.repositories.test.ts | 59 ++++++++++--------- 20 files changed, 111 insertions(+), 92 deletions(-) diff --git a/backend/src/controllers/feedback-rating.controller.ts b/backend/src/controllers/feedback-rating.controller.ts index ac199f8..8cb13b1 100644 --- a/backend/src/controllers/feedback-rating.controller.ts +++ b/backend/src/controllers/feedback-rating.controller.ts @@ -2,12 +2,13 @@ import { NextFunction, Request, Response } from "express"; import { checkValidation } from "../middleware/validation.middleware"; import feedbackRatingService from "../services/feedback-rating.service"; import { FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO } from "../dtos/feedback-rating.dto"; +import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; class FeedbackRatingController { async getExtendedFeedbackRating(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id = req.params.id; + const id = req.params.id as ClientsId; const response: FeedbackRatingExtendedResponseDTO | null = await feedbackRatingService.getExtendedFeedbackRatingById(id); res.json(response); } catch(err: any) { diff --git a/backend/src/controllers/feedback.controller.ts b/backend/src/controllers/feedback.controller.ts index f370374..a55595b 100644 --- a/backend/src/controllers/feedback.controller.ts +++ b/backend/src/controllers/feedback.controller.ts @@ -2,12 +2,13 @@ import { NextFunction, Request, Response } from "express"; import { checkValidation } from "../middleware/validation.middleware"; import { FeedbackCreateDTO, FeedbackFilterDTO, FeedbackResponseDTO } from "../dtos/feedback.dto"; import feedbackService from "../services/feedback.service"; +import { FeedbackId } from "../repositories/interfaces/feedback.entity.interface"; class FeedbackController { async getFeedback(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id = +req.params.id; + const id = (+req.params.id) as FeedbackId; const response: FeedbackResponseDTO | null = await feedbackService.getFeedbackById(id); res.json(response); } catch(err: any) { @@ -44,7 +45,7 @@ class FeedbackController { async patchFeedbackReview(req: Request, res: Response, next: NextFunction) { try { checkValidation(req); - const id = +req.params.id; + const id = (+req.params.id) as FeedbackId; const response: FeedbackResponseDTO | null = await feedbackService.updateFeedbackReview(id); res.json(response); } catch(err: any) { diff --git a/backend/src/dtos/demo-limits.dto.ts b/backend/src/dtos/demo-limits.dto.ts index 429a291..16624da 100644 --- a/backend/src/dtos/demo-limits.dto.ts +++ b/backend/src/dtos/demo-limits.dto.ts @@ -1,3 +1,5 @@ +import { DemoLimitId } from "../repositories/interfaces/demo-limits.entity.interface" + export interface DemoLimitsCountDTO { day: string } @@ -8,7 +10,7 @@ export interface DemoLimitsUpdateDTO { } export interface DemoLimitsResponseDTO { - demo_limit_id: number, + demo_limit_id: DemoLimitId, day: string, count: number, last_modified: string diff --git a/backend/src/dtos/feedback.dto.ts b/backend/src/dtos/feedback.dto.ts index 88c0618..83a1077 100644 --- a/backend/src/dtos/feedback.dto.ts +++ b/backend/src/dtos/feedback.dto.ts @@ -1,4 +1,5 @@ import { ClientsId } from "../repositories/interfaces/clients.entity.interface" +import { FeedbackId } from "../repositories/interfaces/feedback.entity.interface" import { UsersId } from "../repositories/interfaces/users.entity.interface" export interface FeedbackCreateDTO { @@ -32,7 +33,7 @@ export interface FeedbackFilterDTO { } export interface FeedbackResponseDTO { - feedback_id: number, + feedback_id: FeedbackId, client_id: ClientsId, user_id: UsersId, rating: number, diff --git a/backend/src/dtos/rate-limits.dto.ts b/backend/src/dtos/rate-limits.dto.ts index e673d23..1cea954 100644 --- a/backend/src/dtos/rate-limits.dto.ts +++ b/backend/src/dtos/rate-limits.dto.ts @@ -1,4 +1,5 @@ import { ClientsId } from "../repositories/interfaces/clients.entity.interface" +import { RateLimitId } from "../repositories/interfaces/rate-limits.entity.interface" import { UsersId } from "../repositories/interfaces/users.entity.interface" export interface RateLimitsCountDTO { @@ -20,7 +21,7 @@ export interface RateLimitsUpdateDTO { } export interface RateLimitsResponseDTO { - rate_limit_id: number, + rate_limit_id: RateLimitId, client_id: ClientsId, user_id: UsersId, day: string, diff --git a/backend/src/repositories/feedback-rating.repository.ts b/backend/src/repositories/feedback-rating.repository.ts index 84e9a82..d0251f6 100644 --- a/backend/src/repositories/feedback-rating.repository.ts +++ b/backend/src/repositories/feedback-rating.repository.ts @@ -5,6 +5,7 @@ import { DBQueryErrorException } from "../utils/exceptions/db.exception"; import { IFindRepository } from "./interfaces/base.repository.interface"; import { FeedbackRating } from "./interfaces/feedback-rating.entity.interface"; import { FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; +import { ClientsId } from "./interfaces/clients.entity.interface"; class FeedbackRatingRepository implements IFindRepository { @@ -14,7 +15,7 @@ IFindRepository { this.table = 'feedback_ratings'; } - async findById(id: string): Promise { + async findById(id: ClientsId): Promise { const filterColumn = 'client_id'; const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; const value = [id]; @@ -92,7 +93,7 @@ IFindRepository { /** * @description Repository function to call only when used within a transaction => needs PoolClient as param. */ - async updateInTa(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { + async updateInTa(client: PoolClient, id: ClientsId, dto: FeedbackRatingUpdateDTO): Promise { const filterColumn = 'client_id'; const sql = `UPDATE ${this.table} SET count = count + $1, rating_sum = rating_sum + $2, last_modified = $3::timestamp diff --git a/backend/src/repositories/feedback.repository.ts b/backend/src/repositories/feedback.repository.ts index 4b23fc6..b52a2ff 100644 --- a/backend/src/repositories/feedback.repository.ts +++ b/backend/src/repositories/feedback.repository.ts @@ -1,7 +1,7 @@ import { PoolClient, QueryResult } from "pg"; import { DBConnection } from "../configs/db"; import { FeedbackFilterDTO, FeedbackResponseDTO, FeedbackUpdateReviewDTO } from "../dtos/feedback.dto"; -import { Feedback } from "./interfaces/feedback.entity.interface"; +import { Feedback, FeedbackId } from "./interfaces/feedback.entity.interface"; import { DBQueryErrorException } from "../utils/exceptions/db.exception"; import { logError } from "../utils/common.utils"; import { mapFilteredQueryValues } from "../utils/repository.utils"; @@ -13,7 +13,7 @@ class FeedbackRepository { this.table = 'feedback_entries'; } - async findById(id: number): Promise { + async findById(id: FeedbackId): Promise { const filterColumn = "feedback_id"; const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; const value = [id]; @@ -51,7 +51,7 @@ class FeedbackRepository { } } - async updateReview(id: number, dto: FeedbackUpdateReviewDTO): Promise { + async updateReview(id: FeedbackId, dto: FeedbackUpdateReviewDTO): Promise { const filterColumn = "feedback_id"; const sql = `UPDATE ${this.table} SET reviewed_on = $1::timestamp, last_modified = $2::timestamp diff --git a/backend/src/repositories/interfaces/demo-limits.entity.interface.ts b/backend/src/repositories/interfaces/demo-limits.entity.interface.ts index 4569370..abbfce5 100644 --- a/backend/src/repositories/interfaces/demo-limits.entity.interface.ts +++ b/backend/src/repositories/interfaces/demo-limits.entity.interface.ts @@ -1,5 +1,7 @@ +export type DemoLimitId = number & { readonly brand: unique symbol }; + export interface DemoLimits { - demo_limit_id: number, + demo_limit_id: DemoLimitId, day: string, count: number, last_modified: string diff --git a/backend/src/repositories/interfaces/feedback.entity.interface.ts b/backend/src/repositories/interfaces/feedback.entity.interface.ts index 16b63c3..79909c8 100644 --- a/backend/src/repositories/interfaces/feedback.entity.interface.ts +++ b/backend/src/repositories/interfaces/feedback.entity.interface.ts @@ -1,8 +1,10 @@ import { ClientsId } from "./clients.entity.interface"; import { UsersId } from "./users.entity.interface"; +export type FeedbackId = number & { readonly brand: unique symbol }; + export interface Feedback { - feedback_id: number, + feedback_id: FeedbackId, client_id: ClientsId, user_id: UsersId, rating: number, diff --git a/backend/src/repositories/interfaces/rate-limits.entity.interface.ts b/backend/src/repositories/interfaces/rate-limits.entity.interface.ts index 0976b24..a915907 100644 --- a/backend/src/repositories/interfaces/rate-limits.entity.interface.ts +++ b/backend/src/repositories/interfaces/rate-limits.entity.interface.ts @@ -1,8 +1,10 @@ import { ClientsId } from "./clients.entity.interface"; import { UsersId } from "./users.entity.interface"; +export type RateLimitId = number & { readonly brand: unique symbol }; + export interface RateLimits { - rate_limit_id: number, + rate_limit_id: RateLimitId, client_id: ClientsId, user_id: UsersId, day: string, diff --git a/backend/src/services/feedback-rating.service.ts b/backend/src/services/feedback-rating.service.ts index 262a87d..b733ade 100644 --- a/backend/src/services/feedback-rating.service.ts +++ b/backend/src/services/feedback-rating.service.ts @@ -8,15 +8,10 @@ import { PoolClient } from 'pg'; import feedbackRatingModel from "../models/feedback-rating.model"; import feedbackRatingRepository from "../repositories/feedback-rating.repository"; import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; +import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; class FeedbackRatingService { - private timeMapTargets: string[]; - - constructor() { - this.timeMapTargets = ['last_modified', 'created_on']; - } - - async getExtendedFeedbackRatingById(id: string): Promise { + async getExtendedFeedbackRatingById(id: ClientsId): Promise { const result: FeedbackRating | null = await feedbackRatingRepository.findById(id); if(!result) { return null; @@ -55,7 +50,7 @@ class FeedbackRatingService { /** * @description Service function to call only when used within a transaction => needs PoolClient as param. */ - async updateFeedbackRatingInTa(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { + async updateFeedbackRatingInTa(client: PoolClient, id: ClientsId, dto: FeedbackRatingUpdateDTO): Promise { dto = feedbackRatingModel.mapFeedbackRatingUpdateDTO(dto); const result: FeedbackRating | null = await feedbackRatingRepository.updateInTa(client, id, dto); if(!result) { diff --git a/backend/src/services/feedback.service.ts b/backend/src/services/feedback.service.ts index aca3a74..3929370 100644 --- a/backend/src/services/feedback.service.ts +++ b/backend/src/services/feedback.service.ts @@ -9,12 +9,11 @@ import * as RepoUtils from "../utils/repository.utils"; import feedbackRatingModel from "../models/feedback-rating.model"; import feedbackModel from "../models/feedback.model"; import feedbackRepository from "../repositories/feedback.repository"; -import { Feedback } from "../repositories/interfaces/feedback.entity.interface"; +import { Feedback, FeedbackId } from "../repositories/interfaces/feedback.entity.interface"; import feedbackRatingService from "./feedback-rating.service"; class FeedbackService { - - async getFeedbackById(id: number): Promise { + async getFeedbackById(id: FeedbackId): Promise { const result = await feedbackRepository.findById(id); return !result ? null : feedbackModel.toFeedbackResponseDTO(result); } @@ -64,7 +63,7 @@ class FeedbackService { }) } - async updateFeedbackReview(id: number): Promise { + async updateFeedbackReview(id: FeedbackId): Promise { const dto: FeedbackUpdateReviewDTO = feedbackModel.generateFeedbackUpdateReviewDTO(); const result: Feedback | null = await feedbackRepository.updateReview(id, dto); return !result ? null : feedbackModel.toFeedbackResponseDTO(result); diff --git a/backend/tests/integration-tests/repositories/feedback.integration.test.ts b/backend/tests/integration-tests/repositories/feedback.integration.test.ts index 7167798..f59032c 100644 --- a/backend/tests/integration-tests/repositories/feedback.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback.integration.test.ts @@ -18,6 +18,7 @@ import { DBQueryErrorException } from '../../../src/utils/exceptions/db.exceptio import feedbackRatingRepository from '../../../src/repositories/feedback-rating.repository'; import { ClientsId } from '../../../src/repositories/interfaces/clients.entity.interface'; import { UsersId } from '../../../src/repositories/interfaces/users.entity.interface'; +import { FeedbackId } from '../../../src/repositories/interfaces/feedback.entity.interface'; const testValidClientId = mockId.clients.valid[0] as ClientsId; const testValidUserId = mockId.users.valid[0] as UsersId; @@ -41,6 +42,8 @@ jest.mock('../../../src/middleware/observe.middleware.ts', () => ({ jest.setTimeout(60000); +const mockValidFeedbackId = mockId.feedback.valid[0] as FeedbackId; + describe('Integration-tests (repository), priority: entity Feedback', () => { let dbTestSetup: DBTestSetup; @@ -72,7 +75,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { const app = createTestApp([], router, apiUrl); - const testParam_id = mockId.feedback.valid[0]; + const testParam_id = mockValidFeedbackId; const testResult: FeedbackResponseDTO | null = { feedback_id: testParam_id, client_id: testValidClientId, @@ -125,7 +128,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); const testResult: FeedbackResponseDTO = { - feedback_id: mockId.feedback.new[0], + feedback_id: mockId.feedback.new[0] as FeedbackId, client_id: testValidClientId, user_id: mockId.users.valid[1] as UsersId, rating: testParam_dto.rating, @@ -160,7 +163,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); const testResult: FeedbackResponseDTO = { - feedback_id: mockId.feedback.new[0], + feedback_id: mockId.feedback.new[0] as FeedbackId, client_id: mockId.clients.valid[1] as ClientsId, user_id: testValidUserId, rating: testParam_dto.rating, @@ -176,7 +179,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { .send(testParam_dto); const testResponse_FeedbackRating = - await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1]); + await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1] as ClientsId); expect(testResponse_Feedback.statusCode).toBe(200); expect(testResponse_Feedback.body).toMatchObject(testResult); @@ -205,7 +208,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp_update); const testResult: FeedbackResponseDTO = { - feedback_id: mockId.feedback.valid[0], + feedback_id: mockValidFeedbackId, client_id: testValidClientId, user_id: testValidUserId, rating: testParam_dto.rating, @@ -222,7 +225,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { .send(testParam_dto); const testResponse_FeedbackRating = - await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[0]); + await feedbackRatingService.getExtendedFeedbackRatingById(testValidClientId); expect(testResponse_Feedback.statusCode).toBe(200); expect(testResponse_Feedback.body).toMatchObject(testResult); @@ -271,7 +274,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const testResponse_Feedback = await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1] as ClientsId}); const testResponse_FeedbackRating = - await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1]); + await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1] as ClientsId); await expect(() => feedbackService.createFeedback(testParam_dto)) .rejects.toThrow(new DBQueryErrorException(mockError)); @@ -282,7 +285,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn updateReview(), result: "SUCCESS"', async () => { const app = createTestApp([], router, apiUrl); - const testParam_id = mockId.feedback.valid[0]; + const testParam_id = mockValidFeedbackId; const mockTimestamp = '2026-01-01T14:00:08.000Z'; // Test without changing created_on this time to see how .spyOn works with multiple calls in process. jest.spyOn(CommonUtils, "getTimestampUTC") diff --git a/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts b/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts index 9df3e7a..0f5fb91 100644 --- a/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts +++ b/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts @@ -14,9 +14,12 @@ import demoLimitsService from "../../../src/services/demo-limits.service"; import { DemoLimitsCountDTO, DemoLimitsResponseDTO } from "../../../src/dtos/demo-limits.dto"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; +import { RateLimitId } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { DemoLimitId } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; jest.setTimeout(60000); +const testValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitId; const testValidClientId = mockId.clients.valid[0] as ClientsId; const testValidUserId = mockId.users.valid[0] as UsersId; @@ -79,7 +82,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await rateLimitsService.createRateLimit(mockParam_dto); const mockResponse: RateLimitsResponseDTO = { - rate_limit_id: 2, + rate_limit_id: mockId.rate_limits.new[0] as RateLimitId, client_id: testValidClientId, user_id: testValidUserId, day: '2025-01-02', @@ -104,7 +107,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await rateLimitsService.updateRateLimit(dto); const mockResponse: RateLimitsResponseDTO = { - rate_limit_id: 1, + rate_limit_id: testValidRateLimitId, client_id: testValidClientId, user_id: testValidUserId, day: '2025-01-01', @@ -158,7 +161,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await demoLimitsService.createDemoLimit(); const mockResponse: DemoLimitsResponseDTO = { - demo_limit_id: 2, + demo_limit_id: 2 as DemoLimitId, day: '2025-01-02', count: 1, last_modified: testTimestamp @@ -177,7 +180,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await demoLimitsService.updateDemoLimit(); const mockResponse: DemoLimitsResponseDTO = { - demo_limit_id: 1, + demo_limit_id: 1 as DemoLimitId, day: '2025-01-01', count: 2, last_modified: testTimestamp diff --git a/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts b/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts index ff7d58c..84283fe 100644 --- a/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts @@ -22,9 +22,11 @@ import { UsersId } from "../../../../src/repositories/interfaces/users.entity.in // Ensure correct type by converting secret to number via unary + operator. import { secrets } from "../../../../src/utils/secrets.utils" +import { TicketsId } from "../../../../src/repositories/interfaces/tickets.entity.interface"; describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () => { + const mockValidTicketId = mockId.tickets.valid[0] as TicketsId; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; let mockRetryAfter: string; @@ -38,7 +40,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () }; mockBurstContext = [ { - ticket_id: 'valid_tickets_test_id_0', + ticket_id: mockValidTicketId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -50,7 +52,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () created_on: '2025-01-01T14:00:04.000Z' }, { - ticket_id: 'valid_tickets_test_id_1', + ticket_id: 'valid_tickets_test_id_1' as TicketsId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -86,7 +88,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () const ruleCBL = new ClientsBurstLimitRule(3); const mockContext = structuredClone(mockBurstContext); mockContext.push({ - ticket_id: 'valid_tickets_test_id_2', + ticket_id: 'valid_tickets_test_id_2' as TicketsId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, @@ -134,7 +136,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () const ruleUBL = new UsersBurstLimitRule(3); const mockContext = structuredClone(mockBurstContext); mockContext.push({ - ticket_id: 'valid_tickets_test_id_2', + ticket_id: 'valid_tickets_test_id_2' as TicketsId, client_id: mockValidClientId, user_id: mockValidUserId, status: TicketStatus.ISSUED, diff --git a/backend/tests/unit-tests/models/feedback.model.test.ts b/backend/tests/unit-tests/models/feedback.model.test.ts index 2c84fe7..34d1093 100644 --- a/backend/tests/unit-tests/models/feedback.model.test.ts +++ b/backend/tests/unit-tests/models/feedback.model.test.ts @@ -2,13 +2,14 @@ import { FeedbackRatingResponseDTO } from "../../../src/dtos/feedback-rating.dto import { FeedbackCreateDTO, FeedbackResponseDTO, FeedbackUpdateReviewDTO } from "../../../src/dtos/feedback.dto"; import feedbackModel from "../../../src/models/feedback.model"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; -import { Feedback } from "../../../src/repositories/interfaces/feedback.entity.interface"; +import { Feedback, FeedbackId } from "../../../src/repositories/interfaces/feedback.entity.interface"; import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as mockId from "../../mock-data/id.mock-data.json"; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; +const mockValidFeedbackId = mockId.feedback.valid[0] as FeedbackId; const mockTimestamp = '2025-01-01T14:00:08.000Z'; describe('Unit-tests (model), priority: entity Feedback', () => { @@ -16,7 +17,7 @@ describe('Unit-tests (model), priority: entity Feedback', () => { let mockEntity: Feedback; beforeEach(() => { mockEntity = { - feedback_id: mockId.feedback.valid[0], + feedback_id: mockValidFeedbackId, client_id: mockValidClientId, user_id: mockValidUserId, rating: 5, @@ -125,7 +126,7 @@ describe('Unit-tests (model), priority: entity Feedback', () => { const originalEntity: Feedback = structuredClone(mockEntity); originalEntity.reviewed_on = mockTimestamp; const newEntity: Feedback = { - feedback_id: mockId.feedback.valid[1], + feedback_id: mockId.feedback.valid[1] as FeedbackId, client_id: mockValidClientId, user_id: mockId.users.valid[1] as UsersId, rating: 4, diff --git a/backend/tests/unit-tests/models/rate-limits.models.test.ts b/backend/tests/unit-tests/models/rate-limits.models.test.ts index 17335a0..e63c192 100644 --- a/backend/tests/unit-tests/models/rate-limits.models.test.ts +++ b/backend/tests/unit-tests/models/rate-limits.models.test.ts @@ -2,14 +2,15 @@ import { RateLimitsCreateDTO } from "../../../src/dtos/rate-limits.dto"; import demoLimitsModel from "../../../src/models/demo-limits.model"; import rateLimitsModel from "../../../src/models/rate-limits.model"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; -import { DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; -import { RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { DemoLimitId, DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; +import { RateLimitId, RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; describe('Unit-tests (model), priority: entity RateLimits', () => { + const testValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitId; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; @@ -20,7 +21,7 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { test('Sum up count of entries, params: [].length === 2', () => { const mockParam_data: RateLimits[] = [ { - rate_limit_id: mockId.rate_limits.valid[0], + rate_limit_id: testValidRateLimitId, client_id: mockValidClientId, user_id: mockValidUserId, day: '2025-01-01', @@ -28,7 +29,7 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { last_modified: '2025-01-01T14:00:05.000Z' }, { - rate_limit_id: mockId.rate_limits.valid[1], + rate_limit_id: mockId.rate_limits.valid[1] as RateLimitId, client_id: mockValidClientId, user_id: 'another_valid_users_test_id' as UsersId, day: '2025-01-01', @@ -46,7 +47,7 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { test('Sum up count of entries, params: [].length === 1', () => { const mockParam_data: RateLimits[] = [ { - rate_limit_id: mockId.rate_limits.valid[0], + rate_limit_id: testValidRateLimitId, client_id: mockValidClientId, user_id: mockValidUserId, day: '2025-01-01', @@ -111,7 +112,7 @@ describe('Unit-tests (model), priority: entity DemoLimits', () => { test('Sum up count of entries, params: [].length === 1', () => { const mockParam_data: DemoLimits[] = [ { - demo_limit_id: 1, + demo_limit_id: 1 as DemoLimitId, day: '2025-01-01', count: 4, last_modified: '2025-01-01T14:00:05.000Z' diff --git a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts index 7ea99a7..b84bbd0 100644 --- a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -40,7 +40,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }); test('Return data for existing entry, params: valid ', async () => { - const mockParam_id = mockId.clients.valid[0]; + const mockParam_id = mockValidClientId; const mockResult: FeedbackRating = structuredClone(mockData); const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await feedbackRatingRepository.findById(mockParam_id); @@ -54,7 +54,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) test('Return null for non-existing entry, params: non-existing ', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockResult = null; const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await feedbackRatingRepository.findById(mockParam_id); @@ -71,7 +71,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -267,7 +267,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_id = mockId.clients.invalid[0]; + const mockParam_id = mockId.clients.invalid[0] as ClientsId; const mockValues: any[] = [mockParam_dto.rating]; const mockResult: FeedbackRating | null = null; diff --git a/backend/tests/unit-tests/repositories/feedback.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts index 882fcc5..712d5c2 100644 --- a/backend/tests/unit-tests/repositories/feedback.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts @@ -2,7 +2,7 @@ import * as MockUtils from "../../common.test-utils"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; import { DBQueryErrorException } from "../../../src/utils/exceptions/db.exception"; -import { Feedback } from "../../../src/repositories/interfaces/feedback.entity.interface"; +import { Feedback, FeedbackId } from "../../../src/repositories/interfaces/feedback.entity.interface"; import { DBConnection } from "../../../src/configs/db"; import feedbackRepository from "../../../src/repositories/feedback.repository"; import { FeedbackFilterDTO, FeedbackUpdateReviewDTO } from "../../../src/dtos/feedback.dto"; @@ -17,9 +17,10 @@ jest.mock("../../../src/configs/db", () => { } }); +const mockValidFeedbackId = mockId.feedback.valid[0] as FeedbackId; const mockTimestamp = '2025-01-01T14:00:08.000Z'; const mockData: Feedback = { - feedback_id: mockId.feedback.valid[0], + feedback_id: mockValidFeedbackId, client_id: mockId.clients.valid[0] as ClientsId, user_id: mockId.users.valid[0] as UsersId, rating: 5, @@ -43,7 +44,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { }); test('Return data for existing entry, params: valid ', async () => { - const mockParam_id = mockId.feedback.valid[0]; + const mockParam_id = mockValidFeedbackId; const mockResult: Feedback = structuredClone(mockData); const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await feedbackRepository.findById(mockParam_id); @@ -57,7 +58,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { }) test('Return null for non-existing entry, params: non-existing ', async () => { - const mockParam_id = mockId.feedback.invalid[0]; + const mockParam_id = mockId.feedback.invalid[0] as FeedbackId; const mockResult = null; const mockClient = MockUtils.mapMockDbClient(mockResult); const testFn = await feedbackRepository.findById(mockParam_id); @@ -74,7 +75,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { describe('Testing invalid fn calls', () => { test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.feedback.invalid[0]; + const mockParam_id = mockId.feedback.invalid[0] as FeedbackId; const mockErrorMsg = "DB ERROR ON SELECT QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); @@ -188,7 +189,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { describe('Testing valid fn calls', () => { test('Return data of changed entry, params: valid ', async () => { - const mockParam_id = mockId.feedback.valid[0]; + const mockParam_id = mockValidFeedbackId; const mockValues: any[] = [mockParam_dto.reviewed_on, mockParam_dto.last_modified]; const mockResult: Feedback | null = structuredClone(mockData); @@ -204,7 +205,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { }) test('Return data of changed entry, params: invalid ', async () => { - const mockParam_id = mockId.feedback.invalid[0]; + const mockParam_id = mockId.feedback.invalid[0] as FeedbackId; const mockValues: any[] = [mockParam_dto.reviewed_on, mockParam_dto.last_modified]; const mockResult: Feedback | null = null; @@ -223,7 +224,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { describe('Testing invalid fn calls', () => { test('', async () => { - const mockParam_id = mockId.feedback.invalid[0]; + const mockParam_id = mockId.feedback.invalid[0] as FeedbackId; const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; const mockResult = null; jest.spyOn(CommonUtils, "logError").mockReturnValue(); diff --git a/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts b/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts index 4be3e16..385696d 100644 --- a/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts @@ -9,8 +9,8 @@ import { DBQueryErrorException } from "../../../src/utils/exceptions/db.exceptio import * as MockUtils from "../../common.test-utils"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; -import { RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; -import { DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; +import { RateLimitId, RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { DemoLimitId, DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; import { DemoLimitsCountDTO, DemoLimitsResponseDTO, DemoLimitsUpdateDTO } from "../../../src/dtos/demo-limits.dto"; import demoLimitsRepository from "../../../src/repositories/demo-limits.repository"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; @@ -24,10 +24,11 @@ jest.mock("../../../src/configs/db", () => { } }); +const mockValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitId; +const mockValidClientId = mockId.clients.valid[0] as ClientsId; +const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:05.000Z'; const mockDate = '2025-01-01'; -const mockValidClientsId = mockId.clients.valid[0] as ClientsId; -const mockValidUsersId = mockId.users.valid[0] as UsersId; const expectExceptionResult = DBQueryErrorException; const mockBoolean = false; @@ -40,7 +41,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { let mockParam_dto_countById: RateLimitsCountDTO; let mockParam_dto_countByDate: RateLimitsCountDTO; beforeEach(() => { - mockParam_dto_countById = { client_id: mockValidClientsId, day: '2025-01-01' }; + mockParam_dto_countById = { client_id: mockValidClientId, day: '2025-01-01' }; mockParam_dto_countByDate = { day: '2025-02-05' }; }) @@ -49,9 +50,9 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return entries by id and day, params: ', async () => { const mockResult: RateLimits[] = [ { - rate_limit_id: mockId.rate_limits.valid[0], - client_id: mockValidClientsId, - user_id: mockValidUsersId, + rate_limit_id: mockValidRateLimitId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-01-01', count: 3, last_modified: '2025-01-01T14:00:05.000Z' @@ -75,17 +76,17 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return entries by day, params: ', async () => { const mockResult: RateLimits[] = [ { - rate_limit_id: mockId.rate_limits.new[0], - client_id: mockValidClientsId, - user_id: mockValidUsersId, + rate_limit_id: mockId.rate_limits.new[0] as RateLimitId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-02-05', count: 1, last_modified: '2025-02-05T04:17:05.000Z' }, { - rate_limit_id: mockId.rate_limits.new[1], - client_id: mockValidClientsId, - user_id: mockValidUsersId, + rate_limit_id: mockId.rate_limits.new[1] as RateLimitId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-02-05', count: 1, last_modified: '2025-02-05T04:38:05.000Z' @@ -126,8 +127,8 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { let mockParam_entity: Partial; beforeEach(() => { mockParam_entity = { - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: mockDate, count: 1, last_modified: mockTimestamp @@ -138,9 +139,9 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return data for created entry, params: ', async () => { const mockResult: RateLimitsResponseDTO = { - rate_limit_id: mockId.rate_limits.valid[0], - client_id: mockValidClientsId, - user_id: mockValidUsersId, + rate_limit_id: mockValidRateLimitId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: mockDate, count: 1, last_modified: mockTimestamp @@ -181,8 +182,8 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { beforeEach(() => { sql = 'UPDATE'; mockParam_dto = { - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-01-01', last_modified: mockTimestamp }; @@ -190,7 +191,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return data of changed entry, params: valid ', async () => { const mockResult: RateLimitsResponseDTO | null = { - rate_limit_id: mockId.rate_limits.valid[0], + rate_limit_id:mockValidRateLimitId, client_id: mockParam_dto.client_id, user_id: mockParam_dto.user_id, day: '2025-01-01', @@ -211,8 +212,8 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return null for non-existing entry, params: invalid ', async () => { const mockParam_dto_null: RateLimitsUpdateDTO = { - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-01-01', last_modified: mockTimestamp }; @@ -235,8 +236,8 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Throw DBQueryErrorException by catch-block', async () => { const mockParam_dto: RateLimitsUpdateDTO = { - client_id: mockValidClientsId, - user_id: mockValidUsersId, + client_id: mockValidClientId, + user_id: mockValidUserId, day: '2025-01-01', last_modified: mockTimestamp }; @@ -266,7 +267,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return entries by day, params: ', async () => { const mockResult: DemoLimits[] = [ { - demo_limit_id: 2, + demo_limit_id: 2 as DemoLimitId, day: mockParam_dto.day, count: 1, last_modified: '2025-02-05T04:17:05.000Z' @@ -316,7 +317,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return data for created entry, params: >', async () => { const mockResult: DemoLimitsResponseDTO = { - demo_limit_id: 1, + demo_limit_id: 1 as DemoLimitId, day: mockDate, count: 1, last_modified: mockTimestamp @@ -364,7 +365,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return data of changed entry, params: valid ', async () => { const mockResult: DemoLimitsResponseDTO | null = { - demo_limit_id: 1, + demo_limit_id: 1 as DemoLimitId, day: '2025-01-01', count: 1, last_modified: mockTimestamp From 9cee0ea4bfbc9ba63687b592a1ae1a7ec5ae9b96 Mon Sep 17 00:00:00 2001 From: yqni13 Date: Mon, 9 Mar 2026 23:35:04 +0100 Subject: [PATCH 4/7] refactor: change routes / rename model functions --- backend/src/models/clients.model.ts | 2 +- backend/src/models/tickets.model.ts | 2 +- backend/src/models/users.model.ts | 2 +- backend/src/routes/meta.route.ts | 4 ++-- backend/src/routes/tickets.route.ts | 2 +- backend/src/routes/users.route.ts | 4 ++-- backend/src/services/clients.service.ts | 2 +- backend/src/services/tickets.service.ts | 2 +- backend/src/services/users.service.ts | 2 +- .../repositories/meta.integration.test.ts | 8 ++++---- .../repositories/tickets.integration.test.ts | 6 +++--- .../repositories/users.integration.test.ts | 8 ++++---- backend/tests/unit-tests/models/clients.models.test.ts | 4 ++-- backend/tests/unit-tests/models/tickets.model.test.ts | 8 ++++---- backend/tests/unit-tests/models/users.models.test.ts | 4 ++-- 15 files changed, 30 insertions(+), 30 deletions(-) diff --git a/backend/src/models/clients.model.ts b/backend/src/models/clients.model.ts index 73e03c1..451a663 100644 --- a/backend/src/models/clients.model.ts +++ b/backend/src/models/clients.model.ts @@ -14,7 +14,7 @@ class ClientsModel { this.timeMapTargets = ['last_use', 'last_modified', 'created_on']; } - mapToCreateResponseDTO(data: Clients, apiKey: string): ClientsCreateResponseDTO { + toClientsCreateResponseDTO(data: Clients, apiKey: string): ClientsCreateResponseDTO { data = CommonUtils.mapObjTimestamps(data, this.timeMapTargets); return { client_id: data.client_id, diff --git a/backend/src/models/tickets.model.ts b/backend/src/models/tickets.model.ts index c014758..e22407f 100644 --- a/backend/src/models/tickets.model.ts +++ b/backend/src/models/tickets.model.ts @@ -6,7 +6,7 @@ import { FilesService } from "../services/files.service"; import { PermissionException } from "../utils/exceptions/auth.exception"; class TicketsModel { - async generateTicket(dto: TicketsCreateDTO, files: Express.Multer.File[] | null): Promise { + async generateTicketEntity(dto: TicketsCreateDTO, files: Express.Multer.File[] | null): Promise { const timestamp = CommonUtils.getTimestampUTC(); const newId = CommonUtils.generateUUID() as TicketsId; let paths: string[] | null = null; diff --git a/backend/src/models/users.model.ts b/backend/src/models/users.model.ts index b0d7b7f..6f32663 100644 --- a/backend/src/models/users.model.ts +++ b/backend/src/models/users.model.ts @@ -4,7 +4,7 @@ import * as CommonUtils from "../utils/common.utils"; import { UserStatus } from "../utils/enums/user-status.enum"; class UsersModel { - generateUser(dto: UsersCreateDTO): Users { + generateUserEntity(dto: UsersCreateDTO): Users { const timestamp = CommonUtils.getTimestampUTC(); return { user_id: CommonUtils.generateUUID() as UsersId, diff --git a/backend/src/routes/meta.route.ts b/backend/src/routes/meta.route.ts index 79ef5c7..26777e9 100644 --- a/backend/src/routes/meta.route.ts +++ b/backend/src/routes/meta.route.ts @@ -15,7 +15,7 @@ const router = Router(); // findById router.get( - '/by-id/:id', + '/id/:id', authAdmin(), byIdSchema, factory(metaController.getMetaById) @@ -23,7 +23,7 @@ router.get( // findByName router.get( - '/by-name/:name', + '/name/:name', authAdmin(), byNameSchema, factory(metaController.getMetaByName) diff --git a/backend/src/routes/tickets.route.ts b/backend/src/routes/tickets.route.ts index 3866b0f..f56083d 100644 --- a/backend/src/routes/tickets.route.ts +++ b/backend/src/routes/tickets.route.ts @@ -22,7 +22,7 @@ const router = Router(); // findById router.get( - '/by-id/:id', + '/id/:id', authAdmin(), getSchema, factory(ticketsController.getTicket) diff --git a/backend/src/routes/users.route.ts b/backend/src/routes/users.route.ts index 181f349..67ec5cd 100644 --- a/backend/src/routes/users.route.ts +++ b/backend/src/routes/users.route.ts @@ -16,7 +16,7 @@ const router = Router(); // findById router.get( - '/by-id/:id', + '/id/:id', authAdmin(), byIdSchema, factory(usersController.getUserById) @@ -24,7 +24,7 @@ router.get( // findByEmail router.get( - '/by-email/:email', + '/email/:email', authAdmin(), byEmailSchema, factory(usersController.getUserByEmail) diff --git a/backend/src/services/clients.service.ts b/backend/src/services/clients.service.ts index 6fc9a16..b0160ba 100644 --- a/backend/src/services/clients.service.ts +++ b/backend/src/services/clients.service.ts @@ -46,7 +46,7 @@ class ClientsService { async createClient(dto: ClientsCreateDTO): Promise { const clientsCreateObj = clientsModel.generateClientsCreateObj(dto); const result = await clientsRepository.create(clientsCreateObj.client); - return clientsModel.mapToCreateResponseDTO(result as Clients, clientsCreateObj.keyRaw); + return clientsModel.toClientsCreateResponseDTO(result as Clients, clientsCreateObj.keyRaw); } async updateClientFlag(id: ClientsId, dto: ClientsFlagUpdateDTO): Promise { diff --git a/backend/src/services/tickets.service.ts b/backend/src/services/tickets.service.ts index 8e5601a..2a2eacf 100644 --- a/backend/src/services/tickets.service.ts +++ b/backend/src/services/tickets.service.ts @@ -39,7 +39,7 @@ class TicketsService { } async createTicket(dto: TicketsCreateDTO, files: Express.Multer.File[] | null): Promise { - const ticket = await ticketsModel.generateTicket(dto, files); + const ticket = await ticketsModel.generateTicketEntity(dto, files); const result = await ticketsRepository.create(ticket); return CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } diff --git a/backend/src/services/users.service.ts b/backend/src/services/users.service.ts index dbd90af..d740749 100644 --- a/backend/src/services/users.service.ts +++ b/backend/src/services/users.service.ts @@ -38,7 +38,7 @@ class UsersService { } async createUser(dto: UsersCreateDTO): Promise { - const user: Users = usersModel.generateUser(dto); + const user: Users = usersModel.generateUserEntity(dto); const result = await usersRepository.create(user); return CommonUtils.mapObjTimestamps(result, this.timeMapTargets) } diff --git a/backend/tests/integration-tests/repositories/meta.integration.test.ts b/backend/tests/integration-tests/repositories/meta.integration.test.ts index 27d2924..927f915 100644 --- a/backend/tests/integration-tests/repositories/meta.integration.test.ts +++ b/backend/tests/integration-tests/repositories/meta.integration.test.ts @@ -86,7 +86,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult) @@ -112,7 +112,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-name/${testParam_name}`); + .get(`${apiUrl}/name/${testParam_name}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult) @@ -256,7 +256,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { describe('All routes, priority: express-validators, location: ', () => { - describe('Route: GET/by-id:id', () => { + describe('Route: GET/id:id', () => { test('Params: , validator: fn isInt() by string', async () => { const testParam_id = 'invalid_test_id'; @@ -269,7 +269,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { }; const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); expect(testResponse.body.headers.data).toEqual([testError]); diff --git a/backend/tests/integration-tests/repositories/tickets.integration.test.ts b/backend/tests/integration-tests/repositories/tickets.integration.test.ts index 5e47c0a..962cefe 100644 --- a/backend/tests/integration-tests/repositories/tickets.integration.test.ts +++ b/backend/tests/integration-tests/repositories/tickets.integration.test.ts @@ -105,7 +105,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -773,7 +773,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }; }) - describe('Route: GET/by-id/:id', () => { + describe('Route: GET/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { const testParam_id = 'invalid-id'; @@ -781,7 +781,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { testError['value'] = testParam_id; const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); expect(testResponse.body.headers.data).toEqual([testError]); diff --git a/backend/tests/integration-tests/repositories/users.integration.test.ts b/backend/tests/integration-tests/repositories/users.integration.test.ts index be26dc1..8d19922 100644 --- a/backend/tests/integration-tests/repositories/users.integration.test.ts +++ b/backend/tests/integration-tests/repositories/users.integration.test.ts @@ -62,7 +62,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -81,7 +81,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-email/${testParam_email}`); + .get(`${apiUrl}/email/${testParam_email}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -265,7 +265,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { describe('All routes, priority: express-validators, location: ', () => { - describe('Route: GET/by-id/:id', () => { + describe('Route: GET/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { const testParam_id = 'invalid-id'; @@ -278,7 +278,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { } const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); expect(testResponse.body.headers.data).toEqual([testError]); diff --git a/backend/tests/unit-tests/models/clients.models.test.ts b/backend/tests/unit-tests/models/clients.models.test.ts index 3ec95db..00ce481 100644 --- a/backend/tests/unit-tests/models/clients.models.test.ts +++ b/backend/tests/unit-tests/models/clients.models.test.ts @@ -24,14 +24,14 @@ let mockData: Clients = { describe('Unit-tests (model), priority: entity Clients', () => { - describe('Priority: fn mapToCreateResponseDTO()', () => { + describe('Priority: fn toClientsCreateResponseDTO()', () => { describe('Testing valid fn calls', () => { test('Map timestamps of clients object, result: dto ClientsCreateResponseDTO', () => { const mockParam_data: Clients = structuredClone(mockData); - const testFn = clientsModel.mapToCreateResponseDTO(mockParam_data, mockVar_apiKey.keyRaw); + const testFn = clientsModel.toClientsCreateResponseDTO(mockParam_data, mockVar_apiKey.keyRaw); const expectResult: ClientsCreateResponseDTO = { client_id: mockParam_data.client_id, name: mockParam_data.name, diff --git a/backend/tests/unit-tests/models/tickets.model.test.ts b/backend/tests/unit-tests/models/tickets.model.test.ts index 8ea3ebc..1e5cee7 100644 --- a/backend/tests/unit-tests/models/tickets.model.test.ts +++ b/backend/tests/unit-tests/models/tickets.model.test.ts @@ -18,7 +18,7 @@ const mockTimestamp = '2025-01-01T14:00:04.000Z'; describe('Unit-tests (model), priority: entity Tickets', () => { - describe('Priority: fn generateTicket()', () => { + describe('Priority: fn generateTicketEntity()', () => { let mockFile_pdf: Express.Multer.File; let mockFile_webp: Express.Multer.File; @@ -68,7 +68,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { jest.spyOn(CommonUtils, "generateUUID").mockReturnValue(mockParam_id); jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); - const testFn = await ticketsModel.generateTicket(mockParam_dto, mockParam_files); + const testFn = await ticketsModel.generateTicketEntity(mockParam_dto, mockParam_files); const expectResult: Tickets = { ticket_id: mockParam_id, client_id: mockParam_dto.client_id, @@ -103,7 +103,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { jest.spyOn(FilesService.prototype, 'uploadFiles').mockImplementation(); jest.spyOn(FilesService.prototype, 'getResourcePaths').mockReturnValue(mockPaths); - const testFn = await ticketsModel.generateTicket(mockParam_dto, mockParam_files); + const testFn = await ticketsModel.generateTicketEntity(mockParam_dto, mockParam_files); const expectResult: Tickets = { ticket_id: mockParam_id, client_id: mockParam_dto.client_id, @@ -142,7 +142,7 @@ describe('Unit-tests (model), priority: entity Tickets', () => { jest.spyOn(FilesService.prototype, 'uploadFiles').mockImplementation(); jest.spyOn(FilesService.prototype, 'getResourcePaths').mockReturnValue(mockPaths); - const testFn = await ticketsModel.generateTicket(mockParam_dto, mockParam_files); + const testFn = await ticketsModel.generateTicketEntity(mockParam_dto, mockParam_files); const expectResult: Tickets = { ticket_id: mockParam_id, client_id: mockParam_dto.client_id, diff --git a/backend/tests/unit-tests/models/users.models.test.ts b/backend/tests/unit-tests/models/users.models.test.ts index 3698476..80cdd07 100644 --- a/backend/tests/unit-tests/models/users.models.test.ts +++ b/backend/tests/unit-tests/models/users.models.test.ts @@ -10,7 +10,7 @@ const mockTimestamp = '2025-01-03T14:00:03.000Z'; describe('Unit-tests (model), priority: entity Users', () => { - describe('Priority: fn generateUser()', () => { + describe('Priority: fn generateUserEntity()', () => { describe('Testing valid fn calls', () => { @@ -23,7 +23,7 @@ describe('Unit-tests (model), priority: entity Users', () => { jest.spyOn(CommonUtils, "generateUUID").mockReturnValue(mockParam_id); jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); - const testFn = usersModel.generateUser(mockParam_dto); + const testFn = usersModel.generateUserEntity(mockParam_dto); const expectResult: Users = { user_id: mockValidUserId, email: mockParam_dto.email, From ba4b05c8d79764828d128a21f19950da8f265e5c Mon Sep 17 00:00:00 2001 From: yqni13 Date: Tue, 10 Mar 2026 00:12:48 +0100 Subject: [PATCH 5/7] docs: update documentation --- README.md | 57 +++++++++++++++++++-------------------- backend/package-lock.json | 2 +- backend/package.json | 2 +- docs/CHANGELOG.md | 12 ++++++++- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 972a775..6e25612 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # yqni13 | support -$\texttt{\color{teal}{v1.4.1}}$ - +$\texttt{\color{teal}{v1.4.4}}$
-
- logo -
+ + + ### Technology @@ -29,7 +30,7 @@ $\texttt{\color{teal}{v1.4.1}}$
-## How to +## 🪄 $\textsf{\color{salmon}Getting started}$ ### Build & Deploy This application server will is hosted by Render in a Docker container and a PostgreSQL database hosted by Neon. Additionally a cron-job is set up to keep the service alive on Render due to 15-min inactivity on free tier plan.
@@ -37,37 +38,33 @@ The development process is structured by the TDD (test driven development) princ
-## Overview -### $\textsf{\color{teal}Features}$ +## 🧩 $\textsf{\color{salmon}Features}$
-
🪲 support/bug/feedback-ticket handling including client + user data
-
✨ counting/adding up ratings and administer rating average
-
📂 file handling (upload/delete) from requests + cloud storage
-
:mag: filtered search for ticket + user data (properties + timespan)
-
:closed_lock_with_key: en/disable application (maintenance mode) triggered by request/logic
-
:key: request verification by api-keys
-
🕵️ request rate limiting + violation handling
+
🪲 Ticket system | Handles support & bug reports per client with status lifecycle and optional file attachments
+
✨ Feedback & Rating system | Abuse-resistant rating system with atomic aggregate updates - one active rating per user per client
+
📂 Cloud file handling | Upload/delete via Cloudflare R2 (S3-compatible) - supporting pdf & images up to 1MB each, max 5 per ticket
+
🔎 Filtered search | Query ticket and user data by properties and/or timespan
+
🔐 Maintenance Mode | Enable/disable application triggered by request or internal logic
+
🕵️ Rate limiting | Request throttling with violation handling
+
🔑 API Key Auth | Client authentication via API keys
-
- -### $\textsf{\color{teal}Tickets}$ -Documentation follows with finished refactoring (task: SUPPORT-65).
-### $\textsf{\color{teal}Feedback/Rating}$ +### $\textsf{\color{teal}Feedback \&\ Rating}$ -Documentation follows with finished refactoring (task: SUPPORT-65). +User can utilize a feedback & rating system to rate the application in use and send criticism or praise. For every client can exist multiple entries for the entity `Feedback` but only one `FeedbackRating` which holds the accumulated data of the pointing feedback entries.
+Resubmissions are handled in the database by an `ON CONFLICT` upsert query [see upsertInTa()](./backend/src/repositories/feedback.repository.ts) on the unique `(client_id, user_id)` constraint, followed by an atomic aggregate update to the 'FeedbackRating' table entry. Both queries are executed within a single transaction to guarantee data consistency. The rating happens numerical (1-5) and returns an average rating value as number with up to 1 decimal place.
### $\textsf{\color{teal}File handling}$ -User can attach files for any support/bug ticket to provide further information (screenshots, images, ...) on their message. Attachments are limited to upload up to `5` files and each file can be up to `1`mb [see validation](./backend/src/middleware/files/validate.files.middleware.ts). Currently only `images` (webp, jpg, jpeg, png) and `pdf` files are supported, but more will follow. Cloud in use is `Cloudflare` (see Figure 1) using S3Client for api communication and files will be deleted when a ticket is closed, canceled or expired (time check). +User can attach files for any support/bug ticket to provide further information (screenshots, images, ...) on their message. Attachments are limited to upload up to `5` files and each file can be up to `1`MB [see validation](./backend/src/middleware/files/validate.files.middleware.ts). Currently only `images` (webp, jpg, jpeg, png) and `pdf` files are supported, but more will follow. Cloud in use is `Cloudflare` (see Figure 1) using S3Client for api communication and files will be deleted when a ticket is closed, canceled or expired (time check).
 Cloudflare upload demo Figure 1 - Cloudflare upload demo, v1.0.0 @@ -86,7 +83,7 @@ To monitor errors the logging framework `Winston` is used in combination with Lo
-## Testing +## 🔧 $\textsf{\color{salmon}Testing}$ ### $\textsf{\color{teal}Demo}$ @@ -155,22 +152,22 @@ Preventing an unwanted merge with unfinished/failed test run, the project is set
-## Updates +## 📝 $\textsf{\color{salmon}Updates}$ [see changelog for all updates](/docs/CHANGELOG.md) -### $\textsf{\color{forestgreen}last update:}$ -$\textsf{[v1.3.5\ =>\ {\textbf{\color{brown}v1.4.1}]}}$ app
-$\textsf{[v1.5.4\ =>\ {\textbf{\color{brown}v1.6.0}]}}$ database -- $\textsf{\color{teal}Addition:}$ Added api route + logic for Feedback/FeedbackRating to add new feedback/rating or get current average rating value. +$\textsf{[v1.4.1\ =>\ {\textbf{\color{brown}v1.4.4}]}}$ app
- $\textsf{\color{orange}Patch:}$ Updated: - + testing whole process (request-to-response) with individual injection of middlewares when necessary. - + database with new migration to add tables 'feedback_entries' and 'feedback_ratings' to handle single feedback/ratings seperately from accumulated average rating. + + entity ID's are using now nominal types instead basic string|number. + + some model functions are renamed to keep consistency and improve readability. + + some api routes have been shortened to keep consistency and improve readability.
### Update objectives:
+
- caching layer
+
- background worker
- jenkins setup
- mail setup
\ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index ab057c9..e4bee7f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,6 +1,6 @@ { "name": "support_backend", - "version": "1.4.1", + "version": "1.4.4", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/backend/package.json b/backend/package.json index 61b0909..7980648 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "support_backend", - "version": "1.4.1", + "version": "1.4.4", "appMeta": { "db_version": "1.6.0", "docker_image": "yqni13/support", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a020ec8..4d6e52d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,9 +4,19 @@
+### $\textsf{\color{skyblue}2026/03/09}$ + +$\textsf{[v1.4.1\ =>\ {\textbf{\color{brown}v1.4.4}]}}$ app
+- $\textsf{\color{orange}Patch:}$ Updated: + + entity ID's are using now nominal types instead basic string|number. + + some model functions are renamed to keep consistency and improve readability. + + some api routes have been shortened to keep consistency and improve readability. + +
+ ### $\textsf{\color{skyblue}2026/03/07}$ -$\textsf{[v1.3.5\ =>\ {\textbf{\color{brown}v1.4.1}]}}$ app
+$\textsf{[v1.3.5\ =>\ v1.4.1]}$ app
$\textsf{[v1.5.4\ =>\ {\textbf{\color{brown}v1.6.0}]}}$ database - $\textsf{\color{teal}Addition:}$ Added api route + logic for Feedback/FeedbackRating to add new feedback/rating or get current average rating value. - $\textsf{\color{orange}Patch:}$ Updated: From 17e192e4d43e96953267008785ec316c81129bad Mon Sep 17 00:00:00 2001 From: yqni13 Date: Wed, 11 Mar 2026 01:50:56 +0100 Subject: [PATCH 6/7] refactor: code review changes Refactor code regarding Code Review. >> SUPPORT-65-nominal-typed-identifiers #83 --- .github/workflows/main.yml | 4 +- README.md | 95 ++++---- assets/img/logo.png | Bin 45866 -> 0 bytes assets/img/readme-bg.png | Bin 41259 -> 0 bytes backend/package-lock.json | 224 +++++++++++------- backend/src/configs/config.ts | 26 +- backend/src/configs/db.ts | 4 +- backend/src/dtos/demo-limits.dto.ts | 4 +- backend/src/dtos/feedback.dto.ts | 3 +- backend/src/dtos/rate-limits.dto.ts | 4 +- backend/src/dtos/tickets.dto.ts | 11 +- backend/src/dtos/users.dto.ts | 7 +- .../src/middleware/auth.admin.middleware.ts | 2 +- .../penalties.interface.middleware.ts | 6 +- .../rules/rate-limits.rule.middleware.ts | 3 +- backend/src/models/clients.model.ts | 6 +- backend/src/models/test.model.ts | 4 +- backend/src/models/tickets.model.ts | 2 +- backend/src/models/users.model.ts | 2 +- .../src/repositories/feedback.repository.ts | 53 +++-- .../demo-limits.entity.interface.ts | 4 +- .../rate-limits.entity.interface.ts | 4 +- backend/src/services/feedback.service.ts | 7 +- backend/src/services/test.service.ts | 6 +- backend/src/utils/common.utils.ts | 17 +- backend/src/utils/custom-types.utils.ts | 4 +- backend/src/utils/exceptions/db.exception.ts | 6 + backend/src/utils/repository.utils.ts | 8 +- backend/src/utils/secrets.utils.ts | 44 +--- .../schemata/tickets.schema.validation.ts | 7 +- .../schemata/users.schema.validation.ts | 7 +- backend/tests/common.test-utils.ts | 3 +- .../repositories/clients.integration.test.ts | 6 +- .../feedback-rating.integration.test.ts | 14 +- .../repositories/feedback.integration.test.ts | 67 ++++-- .../repositories/meta.integration.test.ts | 11 +- .../rate-limits.integration.test.ts | 12 +- .../repositories/tickets.integration.test.ts | 20 +- .../repositories/users.integration.test.ts | 24 +- backend/tests/mock-data/id.mock-data.json | 9 +- .../middleware/auth.admin.middleware.test.ts | 2 +- .../middleware/engines.middleware.test.ts | 4 +- .../rate-limits.rules.middleware.test.ts | 5 +- .../unit-tests/models/clients.models.test.ts | 4 +- .../models/feedback-rating.model.test.ts | 2 - .../models/rate-limits.models.test.ts | 16 +- .../unit-tests/models/users.models.test.ts | 2 +- .../repositories/clients.repositories.test.ts | 2 +- .../repositories/db.repositories.test.ts | 8 +- .../feedback-rating.repositories.test.ts | 2 +- .../rate-limits.repositories.test.ts | 16 +- .../repositories/tickets.repositories.test.ts | 16 +- docs/CONFIGURATION.md | 12 + docs/DEVOPS.md | 5 +- docs/GLOSSARY.md | 1 + docs/MIGRATION.md | 45 ++-- 56 files changed, 483 insertions(+), 399 deletions(-) delete mode 100644 assets/img/logo.png delete mode 100644 assets/img/readme-bg.png create mode 100644 docs/CONFIGURATION.md diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48efc8f..8c440b9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,12 +31,10 @@ jobs: env: NODE_ENV: ${{ secrets.NODE_ENV }} - SECRET_ADMIN_API: ${{ secrets.SECRET_ADMIN_API }} + SECRET_ADMIN_KEY: ${{ secrets.SECRET_ADMIN_KEY }} SECRET_EMAIL_RECEIVER: ${{ secrets.SECRET_EMAIL_RECEIVER }} SECRET_EMAIL_SENDER: ${{ secrets.SECRET_EMAIL_SENDER }} SECRET_EMAIL_PASS: ${{ secrets.SECRET_EMAIL_PASS }} - SECRET_PRIVATE_KEY_ARTDV: none - SECRET_PRIVATE_KEY_TAVA: none SECRET_BETTERSTACK_LOGGING_KEY: ${{ secrets.SECRET_BETTERSTACK_LOGGING_KEY }} SECRET_BETTERSTACK_HOST: ${{ secrets.SECRET_BETTERSTACK_HOST }} SECRET_DB_USER: ${{ secrets.SECRET_DB_USER }} diff --git a/README.md b/README.md index 6e25612..83f9e98 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,75 @@ # yqni13 | support $\texttt{\color{teal}{v1.4.4}}$ +### Support hub - handling feedback & ratings (`/feedback`) and bug/support requests (`/tickets`) including file attachments across multiple applications via REST API built with NodeJS (Typescript), Express & PostgreSQL in Docker container. Created following Test-Driven Development (450+ tests including ephemeral database by testcontainers) and hosting env:prod via Render, Neon and Cloudflare. +
+
+ NodeJS + Express + Jest + Neon + Docker + Jenkins + PostgreSQL + Cloudflare + Betterstack + Testcontainers +
+

- +## 🪄 $\textsf{\color{salmon}Getting started}$ -### Technology -
- NodeJS - Express - Jest - Neon -
-
- Docker - Jenkins - PostgreSQL -
-
- Cloudflare - Betterstack - Testcontainers -
+### $\textsf{\color{teal}Prerequisites}$ +- node: v22+ +- PostgreSQL v17+ (local or hosted like Neon) +- Docker v4.54+ +- Cloudflare R2 bucket (file handling) +- Betterstack Telemetry (logging)
-## 🪄 $\textsf{\color{salmon}Getting started}$ - -### Build & Deploy -This application server will is hosted by Render in a Docker container and a PostgreSQL database hosted by Neon. Additionally a cron-job is set up to keep the service alive on Render due to 15-min inactivity on free tier plan.
-The development process is structured by the TDD (test driven development) principle. +### $\textsf{\color{teal}Local setup}$ +Download or clone project +```sh +git clone https://github.com/yqni13/support +``` +Create new .env file and fill in your credentials/other env data [(see docs)](./docs/CONFIGURATION.md).
+Navigate/cd into project directory ./backend and install dependencies via npm +```sh +npm ci +``` +Run migrations [(see docs)](./docs/MIGRATION.md).
+Start application in local (development) environment: +```sh +npm run start:dev +``` +Alternatively run application in Docker container [(see docs)](./docs/DEVOPS.md).
- ## 🧩 $\textsf{\color{salmon}Features}$ - -
-
🪲 Ticket system | Handles support & bug reports per client with status lifecycle and optional file attachments
-
✨ Feedback & Rating system | Abuse-resistant rating system with atomic aggregate updates - one active rating per user per client
-
📂 Cloud file handling | Upload/delete via Cloudflare R2 (S3-compatible) - supporting pdf & images up to 1MB each, max 5 per ticket
-
🔎 Filtered search | Query ticket and user data by properties and/or timespan
-
🔐 Maintenance Mode | Enable/disable application triggered by request or internal logic
-
🕵️ Rate limiting | Request throttling with violation handling
-
🔑 API Key Auth | Client authentication via API keys
-
- - +| Feature | Description | +|---------|-------------| +| 🪲 Ticket system | Handles support & bug reports per client with status lifecycle and optional file attachments | +| ✨ Feedback & Rating system | Abuse-resistant rating system with atomic aggregate updates - one active rating per user per client | +| 📂 Cloud file handling | Upload/delete via Cloudflare R2 (S3-compatible) - supporting pdf & images up to 1MB each, max 5 per ticket | +| 🔎 Filtered search | Query ticket and user data by properties and/or timespan | +| 🔐 Maintenance Mode | Enable/disable application triggered by request or internal logic | +| 🕵️ Rate limiting | Request throttling with violation handling | +| 🔑 API Key Auth | Client authentication via API keys |
### $\textsf{\color{teal}Feedback \&\ Rating}$ User can utilize a feedback & rating system to rate the application in use and send criticism or praise. For every client can exist multiple entries for the entity `Feedback` but only one `FeedbackRating` which holds the accumulated data of the pointing feedback entries.
-Resubmissions are handled in the database by an `ON CONFLICT` upsert query [see upsertInTa()](./backend/src/repositories/feedback.repository.ts) on the unique `(client_id, user_id)` constraint, followed by an atomic aggregate update to the 'FeedbackRating' table entry. Both queries are executed within a single transaction to guarantee data consistency. The rating happens numerical (1-5) and returns an average rating value as number with up to 1 decimal place. +Resubmissions are handled in the database by an `ON CONFLICT` upsert query [see upsertInTa()](./backend/src/repositories/feedback.repository.ts) on the unique `(client_id, user_id)` constraint, followed by an atomic aggregate update to the 'FeedbackRating' table entry. Both queries are executed within a single transaction to guarantee data consistency.
+The rating happens numerical (1-5) and returns an average rating value as number with up to 1 decimal place.
+Furthermore, if an existing feedback entry has a message stored, but is not reviewed, the feedback gets NOT updated and request throws a specific exception.
@@ -72,7 +83,7 @@ User can attach files for any support/bug ticket to provide further information
-### $\textsf{\color{teal}Logging}$ +## 📝 $\textsf{\color{salmon}Logging}$ To monitor errors the logging framework `Winston` is used in combination with Logtail from `Betterstack` as a Singleton: [config](./backend/src/logger/config.logger.ts)
While working within local (DEV) or test environment, error messages are logged into the consoles. For the deployed environments (STAG/PROD) the logging is set to send logtails to Betterstack (longer storage time than app-hosting service). For easy access and monitoring of error messages, the Betterstack UI client dashboard comes in handy (see Figure 2). Additional meta data (environment + version numbers) help identifying and assigning errors. @@ -122,7 +133,7 @@ Install the packages `@jest/globals`, `@types/jest`, `supertest`, `@testcontaine ```sh npm install jest @jest/globals @types/jest supertest @testcontainers/postgresql testcontainers --save-dev ``` -450+ tests exist currently for models, utils, validators and workflows (integration tests) - see [tests](./backend/tests).
+450+ tests exist currently for models, utils, validators and workflows (integration tests) - [see tests](./backend/tests).
Run tests on local device by including setup for dotenv/config to provide environment variables: ```sh set NODE_ENV=test && jest --setupFiles dotenv/config @@ -152,7 +163,7 @@ Preventing an unwanted merge with unfinished/failed test run, the project is set
-## 📝 $\textsf{\color{salmon}Updates}$ +## 📈 $\textsf{\color{salmon}Updates}$ [see changelog for all updates](/docs/CHANGELOG.md) diff --git a/assets/img/logo.png b/assets/img/logo.png deleted file mode 100644 index 760b6c9e7697e2ecd58aa1e532044073a7e9c982..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45866 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

K*@4rnrR-ii>~@20g^H8tkGVXw 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 HMuh$k(|&4u diff --git a/assets/img/readme-bg.png b/assets/img/readme-bg.png deleted file mode 100644 index 22cc550542ca382bc240a552b82b68ca725ca22b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41259 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" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2851,9 +2830,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4116,13 +4095,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -5920,10 +5899,25 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.0.tgz", + "integrity": "sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.2" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "funding": [ { "type": "github", @@ -5932,7 +5926,8 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6057,9 +6052,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -6288,16 +6283,37 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7777,12 +7793,12 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7795,6 +7811,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7836,21 +7853,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -7883,18 +7901,6 @@ "node": ">= 0.6" } }, - "node_modules/multer/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/multer/node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -7990,6 +7996,29 @@ } } }, + "node_modules/node-pg-migrate/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/node-pg-migrate/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/node-pg-migrate/node_modules/glob": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", @@ -8015,16 +8044,16 @@ } }, "node_modules/node-pg-migrate/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8085,9 +8114,9 @@ } }, "node_modules/nodemon/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -8286,6 +8315,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.2.tgz", + "integrity": "sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8658,9 +8702,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -8757,9 +8801,9 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -9413,9 +9457,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -9576,9 +9620,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { diff --git a/backend/src/configs/config.ts b/backend/src/configs/config.ts index 528e618..e21a57b 100644 --- a/backend/src/configs/config.ts +++ b/backend/src/configs/config.ts @@ -1,12 +1,10 @@ export const Config: any = { - ADMIN_API: process.env.SECRET_ADMIN_API || null, + ADMIN_KEY: process.env.SECRET_ADMIN_KEY || null, ENV_MODE: process.env.NODE_ENV || 'development', PORT: process.env.ENV_PORT || 3000, EMAIL_RECEIVER: process.env.SECRET_EMAIL_RECEIVER || null, EMAIL_SENDER: process.env.SECRET_EMAIL_SENDER || null, EMAIL_PASS: process.env.SECRET_EMAIL_PASS || null, - PRIVATE_KEY_ARTDV: process.env.SECRET_PRIVATE_KEY_ARTDV || null, - PRIVATE_KEY_TAVA: process.env.SECRET_PRIVATE_KEY_TAVA || null, BETTERSTACK_LOGGING_KEY: process.env.SECRET_BETTERSTACK_LOGGING_KEY || null, BETTERSTACK_HOST: process.env.SECRET_BETTERSTACK_HOST || null, DB_USER: process.env.SECRET_DB_USER || null, @@ -14,19 +12,19 @@ export const Config: any = { DB_HOST: process.env.SECRET_DB_HOST || null, DB_PORT: process.env.SECRET_DB_PORT || null, DB_DATABASE: process.env.SECRET_DB_DATABASE || null, - DB_TEST_USER: process.env.SECRET_DB_TEST_USER || null, - DB_TEST_PASS: process.env.SECRET_DB_TEST_PASS || null, - DB_TEST_HOST: process.env.SECRET_DB_TEST_HOST || null, - DB_TEST_PORT: process.env.SECRET_DB_TEST_PORT || null, - DB_TEST_DATABASE: process.env.SECRET_DB_TEST_DATABASE || null, + DB_TEST_USER: process.env.SECRET_DB_TEST_USER || 'testuser', + DB_TEST_PASS: process.env.SECRET_DB_TEST_PASS || 'testpass', + DB_TEST_HOST: process.env.SECRET_DB_TEST_HOST || 'localhost', + DB_TEST_PORT: process.env.SECRET_DB_TEST_PORT || 5432, + DB_TEST_DATABASE: process.env.SECRET_DB_TEST_DATABASE || 'testdb', TEST_APIKEY_RAW: process.env.SECRET_TEST_APIKEY_RAW || null, TEST_APIKEY_HASH: process.env.SECRET_TEST_APIKEY_HASH || null, - RATELIMITS_CLIENTSBURSTLIMIT: process.env.SECRET_RATELIMITS_CLIENTSBURSTLIMIT || null, - RATELIMITS_CLIENTSDAILYLIMIT: process.env.SECRET_RATELIMITS_CLIENTSDAILYLIMIT || null, - RATELIMITS_USERSBURSTLIMIT: process.env.SECRET_RATELIMITS_USERSBURSTLIMIT || null, - RATELIMITS_USERSDAILYLIMIT: process.env.SECRET_RATELIMITS_USERSDAILYLIMIT || null, - RATELIMITS_TOTALDAILYLIMIT: process.env.SECRET_RATELIMITS_TOTALDAILYLIMIT || null, - DEMOLIMITS_TOTALDAILYLIMIT: process.env.SECRET_DEMOLIMITS_TOTALDAILYLIMIT || null, + RATELIMITS_CLIENTSBURSTLIMIT: process.env.SECRET_RATELIMITS_CLIENTSBURSTLIMIT || 99, + RATELIMITS_CLIENTSDAILYLIMIT: process.env.SECRET_RATELIMITS_CLIENTSDAILYLIMIT || 99, + RATELIMITS_USERSBURSTLIMIT: process.env.SECRET_RATELIMITS_USERSBURSTLIMIT || 99, + RATELIMITS_USERSDAILYLIMIT: process.env.SECRET_RATELIMITS_USERSDAILYLIMIT || 99, + RATELIMITS_TOTALDAILYLIMIT: process.env.SECRET_RATELIMITS_TOTALDAILYLIMIT || 99, + DEMOLIMITS_TOTALDAILYLIMIT: process.env.SECRET_DEMOLIMITS_TOTALDAILYLIMIT || 20, CLOUD_BUCKET: process.env.SECRET_CLOUD_BUCKET || null, CLOUD_ENDPOINT: process.env.SECRET_CLOUD_ENDPOINT || null, CLOUD_ACCESS_KEY_ID: process.env.SECRET_CLOUD_ACCESS_KEY_ID || null, diff --git a/backend/src/configs/db.ts b/backend/src/configs/db.ts index 1449b69..499256b 100644 --- a/backend/src/configs/db.ts +++ b/backend/src/configs/db.ts @@ -16,7 +16,7 @@ export class DBConnection { #pool: pg.Pool; constructor() { - const connectionString = this._getConnectionString(secrets.ENV_MODE); + const connectionString = this.getConnectionString(secrets.ENV_MODE); this.#pool = new pg.Pool({connectionString}); } @@ -27,7 +27,7 @@ export class DBConnection { return DBConnection.instance; } - _getConnectionString(env: string) { + private getConnectionString(env: string) { // Remove white spaces to be comparable with enum values. env = env.trim() as EnvMode; let db: string; diff --git a/backend/src/dtos/demo-limits.dto.ts b/backend/src/dtos/demo-limits.dto.ts index 16624da..7f458b4 100644 --- a/backend/src/dtos/demo-limits.dto.ts +++ b/backend/src/dtos/demo-limits.dto.ts @@ -1,4 +1,4 @@ -import { DemoLimitId } from "../repositories/interfaces/demo-limits.entity.interface" +import { DemoLimitsId } from "../repositories/interfaces/demo-limits.entity.interface" export interface DemoLimitsCountDTO { day: string @@ -10,7 +10,7 @@ export interface DemoLimitsUpdateDTO { } export interface DemoLimitsResponseDTO { - demo_limit_id: DemoLimitId, + demo_limit_id: DemoLimitsId, day: string, count: number, last_modified: string diff --git a/backend/src/dtos/feedback.dto.ts b/backend/src/dtos/feedback.dto.ts index 83a1077..e042d8c 100644 --- a/backend/src/dtos/feedback.dto.ts +++ b/backend/src/dtos/feedback.dto.ts @@ -43,5 +43,6 @@ export interface FeedbackResponseDTO { message?: string, reviewed_on?: string, last_modified: string, - created_on: string + created_on: string, + blocked?: boolean } diff --git a/backend/src/dtos/rate-limits.dto.ts b/backend/src/dtos/rate-limits.dto.ts index 1cea954..e6cc154 100644 --- a/backend/src/dtos/rate-limits.dto.ts +++ b/backend/src/dtos/rate-limits.dto.ts @@ -1,5 +1,5 @@ import { ClientsId } from "../repositories/interfaces/clients.entity.interface" -import { RateLimitId } from "../repositories/interfaces/rate-limits.entity.interface" +import { RateLimitsId } from "../repositories/interfaces/rate-limits.entity.interface" import { UsersId } from "../repositories/interfaces/users.entity.interface" export interface RateLimitsCountDTO { @@ -21,7 +21,7 @@ export interface RateLimitsUpdateDTO { } export interface RateLimitsResponseDTO { - rate_limit_id: RateLimitId, + rate_limit_id: RateLimitsId, client_id: ClientsId, user_id: UsersId, day: string, diff --git a/backend/src/dtos/tickets.dto.ts b/backend/src/dtos/tickets.dto.ts index 642c190..d894b47 100644 --- a/backend/src/dtos/tickets.dto.ts +++ b/backend/src/dtos/tickets.dto.ts @@ -1,7 +1,6 @@ import { ClientsId } from "../repositories/interfaces/clients.entity.interface"; import { TicketsId } from "../repositories/interfaces/tickets.entity.interface"; import { UsersId } from "../repositories/interfaces/users.entity.interface"; -import { SingleOrArray } from "../utils/custom-types.utils"; import { DeviceOption } from "../utils/enums/device-option.enum"; import { Flag } from "../utils/enums/flag.enum"; import { TicketOption } from "../utils/enums/ticket-option.enum"; @@ -52,12 +51,12 @@ export interface TicketsUpdateDTO { } export interface TicketsFilterDTO { - client_id?: SingleOrArray, - user_id?: SingleOrArray, + client_id?: ClientsId | ClientsId[], + user_id?: UsersId | UsersId[], title?: string, - status?: SingleOrArray, - option?: SingleOrArray, - flag?: SingleOrArray | null, + status?: TicketStatus | TicketStatus[], + option?: TicketOption | TicketOption[], + flag?: Flag | Flag[] | null, last_modified?: string[], created_on?: string[] } diff --git a/backend/src/dtos/users.dto.ts b/backend/src/dtos/users.dto.ts index 3d43ae4..67ff9c6 100644 --- a/backend/src/dtos/users.dto.ts +++ b/backend/src/dtos/users.dto.ts @@ -1,6 +1,5 @@ import { UserStatus } from "../utils/enums/user-status.enum"; import { Flag } from "../utils/enums/flag.enum"; -import { SingleOrArray } from "../utils/custom-types.utils"; import { UsersId } from "../repositories/interfaces/users.entity.interface"; export interface UsersCreateDTO { @@ -20,9 +19,9 @@ export interface UsersFlagUpdateDTO { } export interface UsersFilterDTO { - email?: SingleOrArray, - status?: SingleOrArray, - flag?: SingleOrArray | null, + email?: string | string[], + status?: UserStatus | UserStatus[], + flag?: Flag | Flag[] | null, last_modified?: string[], created_on?: string[] } diff --git a/backend/src/middleware/auth.admin.middleware.ts b/backend/src/middleware/auth.admin.middleware.ts index 3dd93b8..b20de12 100644 --- a/backend/src/middleware/auth.admin.middleware.ts +++ b/backend/src/middleware/auth.admin.middleware.ts @@ -17,7 +17,7 @@ export function authAdmin() { throw new MissingApiKeyException('support-missing-admin-auth'); } - const hasValidKey = adminKey.trim() === secrets.ADMIN_API.trim(); + const hasValidKey = adminKey.trim() === secrets.ADMIN_KEY.trim(); if(!hasValidKey) { throw new InvalidApiKeyException('support-invalid-admin-auth'); } diff --git a/backend/src/middleware/interfaces/penalties.interface.middleware.ts b/backend/src/middleware/interfaces/penalties.interface.middleware.ts index 5f68f5f..513b3f7 100644 --- a/backend/src/middleware/interfaces/penalties.interface.middleware.ts +++ b/backend/src/middleware/interfaces/penalties.interface.middleware.ts @@ -17,9 +17,9 @@ export interface PenaltyApply { } export type PenaltyContext = - | { type: Violation.CLIENTSFLAG, id: string, penaltyValue: Flag | null } - | { type: Violation.USERSFLAG, id: string, penaltyValue: Flag | null } - | { type: Violation.MAINTENANCE_TRAFFIC, id: number, penaltyValue: MaintenanceMode }; + | { type: Violation.CLIENTSFLAG, id: ClientsId, penaltyValue: Flag | null } + | { type: Violation.USERSFLAG, id: UsersId, penaltyValue: Flag | null } + | { type: Violation.MAINTENANCE_TRAFFIC, id: MetaId, penaltyValue: MaintenanceMode }; export interface PenaltyClientsFlagContext extends BasePenaltyContext { type: Violation.CLIENTSFLAG, diff --git a/backend/src/middleware/rules/rate-limits.rule.middleware.ts b/backend/src/middleware/rules/rate-limits.rule.middleware.ts index 15fac2a..2c09866 100644 --- a/backend/src/middleware/rules/rate-limits.rule.middleware.ts +++ b/backend/src/middleware/rules/rate-limits.rule.middleware.ts @@ -1,6 +1,7 @@ import { DemoLimitsCountDTO } from "../../dtos/demo-limits.dto"; import { RateLimitsCountDTO } from "../../dtos/rate-limits.dto"; import { TicketsIntervalDTO } from "../../dtos/tickets.dto"; +import { MetaId } from "../../repositories/interfaces/meta.entity.interface"; import demoLimitsService from "../../services/demo-limits.service"; import rateLimitsService from "../../services/rate-limits.service"; import ticketsService from "../../services/tickets.service"; @@ -108,7 +109,7 @@ export class TotalDailyLimitRule implements RateLimitsRule { retryAfter: CommonUtils.getNextDayUTC(), penalty: { type: Violation.MAINTENANCE_TRAFFIC, - id: 1, + id: 1 as MetaId, penaltyValue: MaintenanceMode.T011 } }; diff --git a/backend/src/models/clients.model.ts b/backend/src/models/clients.model.ts index 451a663..4c74f10 100644 --- a/backend/src/models/clients.model.ts +++ b/backend/src/models/clients.model.ts @@ -28,7 +28,7 @@ class ClientsModel { }; } - _generateApiKeyObj(): { keyRaw: string, keyHash: string } { + private generateApiKeyObj(): { keyRaw: string, keyHash: string } { const keyLength = 42; const charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const bytes = crypto.randomBytes(keyLength); @@ -43,8 +43,8 @@ class ClientsModel { } generateClientsCreateObj(dto: ClientsCreateDTO): { client: Clients, keyRaw: string } { - const id = CommonUtils.generateUUID() as ClientsId; - const keyObj = this._generateApiKeyObj(); + const id = CommonUtils.generateUUID(); + const keyObj = this.generateApiKeyObj(); const timestamp = CommonUtils.getTimestampUTC(); const client: Clients = { client_id: id, diff --git a/backend/src/models/test.model.ts b/backend/src/models/test.model.ts index 35fab18..d8dfe50 100644 --- a/backend/src/models/test.model.ts +++ b/backend/src/models/test.model.ts @@ -3,7 +3,7 @@ import { MaintenanceMode } from "../utils/enums/maintenance-mode.enum"; import { ExceedMaxEndpointException, UnexpectedApiResponseException, UnimplementedException } from "../utils/exceptions/api.exception"; import { AuthSecretNotFoundException, BlockedUsersException, ForbiddenApiKeyException, InvalidApiKeyException, InvalidUsersException, MalformedApiKeyException, MissingApiKeyException, PermissionException } from "../utils/exceptions/auth.exception"; import { InternalServerException, InvalidSourceException, MaintenanceException, RequestExceedMaxException } from "../utils/exceptions/common.exception"; -import { DBConnectionException, DBEmptyException, DBQueryErrorException } from "../utils/exceptions/db.exception"; +import { DBConnectionException, DBConstraintErrorException, DBEmptyException, DBQueryErrorException } from "../utils/exceptions/db.exception"; import { InvalidFilesException, InvalidPropertiesException } from "../utils/exceptions/validation.exception"; class TestModel { @@ -44,6 +44,8 @@ class TestModel { throw new DBEmptyException(); case('DBQueryErrorException'): throw new DBQueryErrorException(); + case('DBConstraintErrorException'): + throw new DBConstraintErrorException(dto.errorMsg ?? this.substitutionMsg); // ValidationException case('InvalidPropertiesException'): { const customError = [ diff --git a/backend/src/models/tickets.model.ts b/backend/src/models/tickets.model.ts index e22407f..2b011ee 100644 --- a/backend/src/models/tickets.model.ts +++ b/backend/src/models/tickets.model.ts @@ -8,7 +8,7 @@ import { PermissionException } from "../utils/exceptions/auth.exception"; class TicketsModel { async generateTicketEntity(dto: TicketsCreateDTO, files: Express.Multer.File[] | null): Promise { const timestamp = CommonUtils.getTimestampUTC(); - const newId = CommonUtils.generateUUID() as TicketsId; + const newId = CommonUtils.generateUUID(); let paths: string[] | null = null; if(files) { const filesService = new FilesService(files, 'tickets'); diff --git a/backend/src/models/users.model.ts b/backend/src/models/users.model.ts index 6f32663..849b2d1 100644 --- a/backend/src/models/users.model.ts +++ b/backend/src/models/users.model.ts @@ -7,7 +7,7 @@ class UsersModel { generateUserEntity(dto: UsersCreateDTO): Users { const timestamp = CommonUtils.getTimestampUTC(); return { - user_id: CommonUtils.generateUUID() as UsersId, + user_id: CommonUtils.generateUUID(), email: dto.email, status: UserStatus.ACTIVE, flag: null, diff --git a/backend/src/repositories/feedback.repository.ts b/backend/src/repositories/feedback.repository.ts index b52a2ff..d218d03 100644 --- a/backend/src/repositories/feedback.repository.ts +++ b/backend/src/repositories/feedback.repository.ts @@ -76,9 +76,21 @@ class FeedbackRepository { } /** + * Upsert feedback within a transaction. * - * @description Update on insert conflict for existing client_id and user_id combined entry (unique constraint). Is called within transaction only => needs PoolClient as param. - * @returns {FeedbackResponseDTO | null} Entity expanded by rating_old value for further processing. + * Uses a CTE-based pattern to handle the following cases: + * - UDPATE: Existing entry => updates and returns row + * - INSERT: Non existing entry (unique constraint for client_id & user_id combination) => creates new row + * - BLOCK: Existing entry with message but without review => update is prevented by WHERE NOT clause. + * Returns existing row with `blocked: true` to handle specific response. + * + * Notes: + * - UNION ALL: Combine results of multiple SELECT statements. + * - ON CONFLICT: PostgreSQL does NOT have UPSERT statement => supports INSERT...ON CONFLICT instead (or MERGE). + * + * @param {PoolClient} client Client used to connect database as this fn is called within a transaction. + * @param {Partial} entity All properties needed except 'feedback_id' due to serial type in database. + * @returns {FeedbackResponseDTO | null} FeedbackResponseDTO expands `Feedback` by 'rating_old' and 'blocked' value for further processing => `blocked: true` for prevented update or `NULL` if nothing was found (unexpected). */ async upsertInTa(client: PoolClient, entity: Partial): Promise { const sql = ` @@ -86,21 +98,30 @@ class FeedbackRepository { SELECT rating FROM ${this.table} WHERE client_id = $1 AND user_id = $2 + ), + upsert AS ( + INSERT INTO ${this.table} + (client_id, user_id, rating, term_accepted, message, reviewed_on, last_modified, created_on) + VALUES ($1, $2, $3, $4, $5, $6, $7::timestamp, $8::timestamp) + ON CONFLICT (client_id, user_id) + DO UPDATE SET + rating = EXCLUDED.rating, + term_accepted = EXCLUDED.term_accepted, + message = EXCLUDED.message, + reviewed_on = EXCLUDED.reviewed_on, + last_modified = EXCLUDED.last_modified + WHERE NOT (${this.table}.message IS NOT NULL AND ${this.table}.reviewed_on IS NULL) + RETURNING + ${this.table}.*, + (SELECT rating FROM pre_update_data) AS rating_old, + false AS blocked ) - INSERT INTO ${this.table} - (client_id, user_id, rating, term_accepted, message, reviewed_on, last_modified, created_on) - VALUES ($1, $2, $3, $4, $5, $6, $7::timestamp, $8::timestamp) - ON CONFLICT (client_id, user_id) - DO UPDATE SET - rating = EXCLUDED.rating, - term_accepted = EXCLUDED.term_accepted, - message = EXCLUDED.message, - reviewed_on = EXCLUDED.reviewed_on, - last_modified = EXCLUDED.last_modified - WHERE NOT (${this.table}.message IS NOT NULL AND ${this.table}.reviewed_on IS NOT NULL) - RETURNING - ${this.table}.*, - (SELECT rating FROM pre_update_data) AS rating_old; + SELECT * FROM upsert + UNION ALL + SELECT *, NULL AS rating_old, true AS blocked + FROM ${this.table} + WHERE client_id = $1 AND user_id = $2 + AND NOT EXISTS (SELECT 1 FROM upsert) `; const values = [entity.client_id, entity.user_id, entity.rating, entity.term_accepted, entity.message, null, entity.last_modified, entity.created_on]; const result: QueryResult = await client.query(sql, values); diff --git a/backend/src/repositories/interfaces/demo-limits.entity.interface.ts b/backend/src/repositories/interfaces/demo-limits.entity.interface.ts index abbfce5..9d756d0 100644 --- a/backend/src/repositories/interfaces/demo-limits.entity.interface.ts +++ b/backend/src/repositories/interfaces/demo-limits.entity.interface.ts @@ -1,7 +1,7 @@ -export type DemoLimitId = number & { readonly brand: unique symbol }; +export type DemoLimitsId = number & { readonly brand: unique symbol }; export interface DemoLimits { - demo_limit_id: DemoLimitId, + demo_limit_id: DemoLimitsId, day: string, count: number, last_modified: string diff --git a/backend/src/repositories/interfaces/rate-limits.entity.interface.ts b/backend/src/repositories/interfaces/rate-limits.entity.interface.ts index a915907..2260ade 100644 --- a/backend/src/repositories/interfaces/rate-limits.entity.interface.ts +++ b/backend/src/repositories/interfaces/rate-limits.entity.interface.ts @@ -1,10 +1,10 @@ import { ClientsId } from "./clients.entity.interface"; import { UsersId } from "./users.entity.interface"; -export type RateLimitId = number & { readonly brand: unique symbol }; +export type RateLimitsId = number & { readonly brand: unique symbol }; export interface RateLimits { - rate_limit_id: RateLimitId, + rate_limit_id: RateLimitsId, client_id: ClientsId, user_id: UsersId, day: string, diff --git a/backend/src/services/feedback.service.ts b/backend/src/services/feedback.service.ts index 3929370..87c1f40 100644 --- a/backend/src/services/feedback.service.ts +++ b/backend/src/services/feedback.service.ts @@ -11,6 +11,7 @@ import feedbackModel from "../models/feedback.model"; import feedbackRepository from "../repositories/feedback.repository"; import { Feedback, FeedbackId } from "../repositories/interfaces/feedback.entity.interface"; import feedbackRatingService from "./feedback-rating.service"; +import { DBConstraintErrorException } from "../utils/exceptions/db.exception"; class FeedbackService { async getFeedbackById(id: FeedbackId): Promise { @@ -36,6 +37,9 @@ class FeedbackService { let dtoUpdateFR: FeedbackRatingUpdateDTO; if(!result) { return null; + } else if(result.blocked) { + // Update on feedback is not allowed if message exists for entry without being reviewed yet. + throw new DBConstraintErrorException('support-constraint-feedback'); } else if(new Date(result.created_on).getTime() === new Date(entity.created_on!).getTime()) { // New Feedback was created => increase rating_sum. dtoUpdateFR = { count: 1, rating: dto.rating }; @@ -55,7 +59,8 @@ class FeedbackService { }; await feedbackRatingService.createFeedbackRatingInTa(client, dtoCreateFR); } - // Use rating from dto if no other ratings for this client exist. + // Delete blocked info and use rating from dto if no other ratings for this client exist. + delete result['blocked']; return { ...result, rating_average_new: update?.rating_average ?? dto.rating diff --git a/backend/src/services/test.service.ts b/backend/src/services/test.service.ts index de23863..fd4c4df 100644 --- a/backend/src/services/test.service.ts +++ b/backend/src/services/test.service.ts @@ -1,9 +1,8 @@ -import { MetaResponseDTO } from '../dtos/meta.dto'; import { TestDemoDTO, TestErrorDTO } from '../dtos/test.dto'; import testModel from '../models/test.model'; -import { MetaId } from '../repositories/interfaces/meta.entity.interface'; import metaRepository from '../repositories/meta.repository'; import { DemoMode } from '../utils/enums/demo-mode.enum'; +import { secrets } from '../utils/secrets.utils'; class TestService { async searchExceptionThrow(dto: TestErrorDTO) { @@ -14,8 +13,7 @@ class TestService { let result: any; switch(dto.demo_mode) { case(DemoMode.SUCCESS): { - result = await metaRepository.findById(1 as MetaId); - result = { app_version: (result as MetaResponseDTO).app_version }; + result = { app_version: secrets.APP_VERSION }; break; } case(DemoMode.ERROR): diff --git a/backend/src/utils/common.utils.ts b/backend/src/utils/common.utils.ts index d625aec..49aefc1 100644 --- a/backend/src/utils/common.utils.ts +++ b/backend/src/utils/common.utils.ts @@ -13,8 +13,8 @@ const logger = Logger.getLogger(); */ export const now = (): Date => new Date(); -export function generateUUID(): string { - return uuid_v4(); +export function generateUUID(): T { + return uuid_v4() as T; } export function mapKeyToHash(key: string): string { @@ -40,19 +40,6 @@ export function isEmptyObj(obj: any): boolean { return JSON.stringify(obj) === '{}'; } -export function selectPrivateKey(source: MailSource): string { - switch(source) { - case(MailSource.ARTDV): { - return secrets.PRIVATE_KEY_ARTDV; - } - case(MailSource.TAVA): { - return secrets.PRIVATE_KEY_TAVA; - } - default: - throw new InvalidSourceException(); - } -} - export function logError(message: string, method: string, err: any) { logger.error(message, { error: err.error, diff --git a/backend/src/utils/custom-types.utils.ts b/backend/src/utils/custom-types.utils.ts index 50368d7..a14bdec 100644 --- a/backend/src/utils/custom-types.utils.ts +++ b/backend/src/utils/custom-types.utils.ts @@ -1,5 +1,3 @@ import { Request, Response, NextFunction } from "express"; -export type AsyncMiddleware = (req: Request, res: Response, next: NextFunction) => Promise; - -export type SingleOrArray = T | T[]; \ No newline at end of file +export type AsyncMiddleware = (req: Request, res: Response, next: NextFunction) => Promise; \ No newline at end of file diff --git a/backend/src/utils/exceptions/db.exception.ts b/backend/src/utils/exceptions/db.exception.ts index 4a93120..476eed6 100644 --- a/backend/src/utils/exceptions/db.exception.ts +++ b/backend/src/utils/exceptions/db.exception.ts @@ -38,4 +38,10 @@ export class DBQueryErrorException extends DBException { constructor(data?: any) { super('support-dbquery-error', data); } +} + +export class DBConstraintErrorException extends DBException { + constructor(message: string, data?: any) { + super(message, data); + } } \ No newline at end of file diff --git a/backend/src/utils/repository.utils.ts b/backend/src/utils/repository.utils.ts index 0ca5af9..cf6f15d 100644 --- a/backend/src/utils/repository.utils.ts +++ b/backend/src/utils/repository.utils.ts @@ -26,7 +26,7 @@ export function mapFilteredQueryValues>(dto: T, ta const argGroups: string[] = []; const timestampObj = {}; Object.entries(dto).forEach(([key, content]) => { - if(key !== 'reviewed_on' && key !== 'last_modified' && key !== 'created_on') { + if(key !== 'last_modified' && key !== 'created_on') { const valArr = Array.isArray(content) ? content : [content]; const conditions = valArr.map((value) => { if(value === null) { @@ -81,7 +81,11 @@ export async function asTransaction( } catch(err: any) { await client.query('ROLLBACK'); logError(message, method, err); - throw new DBQueryErrorException(err); + if(err.error) { + throw err; + } else { + throw new DBQueryErrorException(err); + } } finally { await db.close(client); } diff --git a/backend/src/utils/secrets.utils.ts b/backend/src/utils/secrets.utils.ts index ba06cef..06f27ae 100644 --- a/backend/src/utils/secrets.utils.ts +++ b/backend/src/utils/secrets.utils.ts @@ -6,14 +6,12 @@ import fs from "fs"; class Secrets { readonly APP_VERSION: string; readonly APP_META: Record; - readonly ADMIN_API: string; + readonly ADMIN_KEY: string; readonly ENV_MODE: string; readonly PORT: number; readonly EMAIL_RECEIVER: string; readonly EMAIL_SENDER: string; readonly EMAIL_PASS: string; - readonly PRIVATE_KEY_ARTDV: string; - readonly PRIVATE_KEY_TAVA: string; readonly BETTERSTACK_LOGGING_KEY: string; readonly BETTERSTACK_HOST: string; readonly DB_USER: string; @@ -44,14 +42,12 @@ class Secrets { private constructor() { this.APP_VERSION = this.getAppVersion(); this.APP_META = this.getAppMeta(); - this.ADMIN_API = this.setAdminApi(); + this.ADMIN_KEY = this.setAdminKey(); this.ENV_MODE = this.setEnvMode(); this.PORT = this.setPort(); this.EMAIL_RECEIVER = this.setEmailReceiver(); this.EMAIL_SENDER = this.setEmailSender(); this.EMAIL_PASS = this.setEmailPass(); - this.PRIVATE_KEY_ARTDV = this.setPrivateKey_ARTDV(); - this.PRIVATE_KEY_TAVA = this.setPrivateKey_TAVA(); this.BETTERSTACK_LOGGING_KEY = this.setBetterStackLoggingKey(); this.BETTERSTACK_HOST = this.setBetterStackHost(); this.DB_USER = this.setDbUser(); @@ -97,11 +93,11 @@ class Secrets { return !packageJson ? null : packageJson.appMeta; } - private setAdminApi() { - if(!Config.ADMIN_API) { - throw new AuthSecretNotFoundException('secret-404-env#ADMIN_API'); + private setAdminKey() { + if(!Config.ADMIN_KEY) { + throw new AuthSecretNotFoundException('secret-404-env#ADMIN_KEY'); } - return Config.ADMIN_API; + return Config.ADMIN_KEY; } private setEnvMode() { @@ -139,34 +135,6 @@ class Secrets { return Config.EMAIL_PASS; } - private setPrivateKey_ARTDV = () => { - let key; - if(Config.MODE === 'development') { - key = !Config.PRIVATE_KEY_ARTDV ? null : fs.readFileSync(Config.PRIVATE_KEY_ARTDV, 'utf8'); - } else { - key = Config.PRIVATE_KEY_ARTDV; - } - - if(!key) { - throw new AuthSecretNotFoundException('secret-404-env#PRIVATE_KEY_ARTDV'); - } - return key; - } - - private setPrivateKey_TAVA = () => { - let key; - if(Config.MODE === 'development') { - key = !Config.PRIVATE_KEY_TAVA ? null : fs.readFileSync(Config.PRIVATE_KEY_TAVA, 'utf8'); - } else { - key = Config.PRIVATE_KEY_TAVA; - } - - if(!key) { - throw new AuthSecretNotFoundException('secret-404-env#PRIVATE_KEY_TAVA'); - } - return key; - } - private setBetterStackLoggingKey = () => { if(!Config.BETTERSTACK_LOGGING_KEY) { throw new AuthSecretNotFoundException('secret-404-env#BETTERSTACK_LOGGING_KEY'); diff --git a/backend/src/validation/schemata/tickets.schema.validation.ts b/backend/src/validation/schemata/tickets.schema.validation.ts index 24aab0f..424ac08 100644 --- a/backend/src/validation/schemata/tickets.schema.validation.ts +++ b/backend/src/validation/schemata/tickets.schema.validation.ts @@ -2,7 +2,6 @@ import { body, param, ValidationChain } from 'express-validator'; import * as CommonValidators from "../common.validation"; import { Flag } from '../../utils/enums/flag.enum'; import { TicketStatus } from '../../utils/enums/ticket-status.enum'; -import { SingleOrArray } from '../../utils/custom-types.utils'; import { CommonExceptionMessage as Message } from '../../utils/enums/common-exception-messages.enum'; import { TicketOption } from '../../utils/enums/ticket-option.enum'; @@ -36,21 +35,21 @@ export const postTicketsSearchSchema: ValidationChain[] = [ .withMessage('support-invalid-max#title!100') .optional(), body('status') - .custom((content: SingleOrArray) => { + .custom((content: TicketStatus | TicketStatus[]) => { content = Array.isArray(content) ? content : [content]; content.forEach((status) => CommonValidators.validateEnum(status, TicketStatus, 'ticketStatus')) return true; }) .optional(), body('option') - .custom((content: SingleOrArray) => { + .custom((content: TicketOption | TicketOption[]) => { content = Array.isArray(content) ? content : [content]; content.forEach((option) => CommonValidators.validateEnum(option, TicketOption, 'ticketOption')) return true; }) .optional(), body('flag') - .custom((content: undefined | null | SingleOrArray) => { + .custom((content: undefined | null | Flag | Flag[]) => { // Manual check for undefined/null necessary because null is valid value. if(content === null || content === undefined) { return true; diff --git a/backend/src/validation/schemata/users.schema.validation.ts b/backend/src/validation/schemata/users.schema.validation.ts index aead4b3..3a08eb1 100644 --- a/backend/src/validation/schemata/users.schema.validation.ts +++ b/backend/src/validation/schemata/users.schema.validation.ts @@ -2,7 +2,6 @@ import { body, param, ValidationChain } from 'express-validator'; import * as CommonValidators from "../common.validation"; import { UserStatus } from '../../utils/enums/user-status.enum'; import { Flag } from '../../utils/enums/flag.enum'; -import { SingleOrArray } from '../../utils/custom-types.utils'; import { CommonExceptionMessage as Message } from '../../utils/enums/common-exception-messages.enum'; export const getUserByIdSchema: ValidationChain[] = [ @@ -22,21 +21,21 @@ export const getUserByEmailSchema: ValidationChain[] = [ export const postUsersSearchSchema: ValidationChain[] = [ body('email') - .custom((content: SingleOrArray) => { + .custom((content: string | string[]) => { content = Array.isArray(content) ? content : [content]; content.forEach((email) => CommonValidators.validateEmail(email)) return true; }) .optional(), body('status') - .custom((content: SingleOrArray) => { + .custom((content: UserStatus | UserStatus[]) => { content = Array.isArray(content) ? content : [content]; content.forEach((status) => CommonValidators.validateEnum(status, UserStatus, 'userStatus')) return true; }) .optional(), body('flag') - .custom((content: undefined | null | SingleOrArray) => { + .custom((content: undefined | null | Flag | Flag[]) => { // Manual check for undefined/null necessary because null is valid value. if(content === null || content === undefined) { return true; diff --git a/backend/tests/common.test-utils.ts b/backend/tests/common.test-utils.ts index 51ccc87..b1cb1ab 100644 --- a/backend/tests/common.test-utils.ts +++ b/backend/tests/common.test-utils.ts @@ -2,6 +2,7 @@ import { QueryResult } from "pg"; import { DBConnection } from "../src/configs/db"; import { NextFunction, Request, Response } from "express"; import { UsersId } from "../src/repositories/interfaces/users.entity.interface"; +import { ClientsId } from "../src/repositories/interfaces/clients.entity.interface"; type MockClient = { query: jest.Mock @@ -41,7 +42,7 @@ export function disableConsoleMessages() { /** * @description Used in combination with createTestApp() to mock client authentication for flexible testing. */ -export function injectTestClientId(clientId: string) { +export function injectTestClientId(clientId: ClientsId) { return function (req: Request, res: Response, next: NextFunction) { (req as any).apiClients = { client_id: clientId }; next(); diff --git a/backend/tests/integration-tests/repositories/clients.integration.test.ts b/backend/tests/integration-tests/repositories/clients.integration.test.ts index 4ac6294..e3831bc 100644 --- a/backend/tests/integration-tests/repositories/clients.integration.test.ts +++ b/backend/tests/integration-tests/repositories/clients.integration.test.ts @@ -115,7 +115,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { const testParam_dto = { name: 'testclient_test_create' }; jest.spyOn(CommonUtils, 'generateUUID').mockReturnValue(testParam_client_id); - jest.spyOn(clientsModel, '_generateApiKeyObj').mockReturnValue(testVar_apiKey); + jest.spyOn(clientsModel as any, 'generateApiKeyObj').mockReturnValue(testVar_apiKey); jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); const testResult: ClientsCreateResponseDTO = { @@ -210,7 +210,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { describe('Route: PUT/status/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID' as ClientsId; const testParam_dto: ClientsStatusUpdateDTO = { status: ApiKeyStatus.EXPIRED }; @@ -306,7 +306,7 @@ describe('Integration-tests (repository), priority: entity Clients', () => { describe('Route: PUT/status/:id', () => { test('Params: , validator: fn requirePayload() by undefined', async () =>{ - const testParam_id = mockId.clients.valid[0]; + const testParam_id = mockValidClientId; const testParam_dto = undefined; const testError = structuredClone(mockError); diff --git a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts index 8ef2288..880a181 100644 --- a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts @@ -6,13 +6,15 @@ import { runMigrations } from '../../db-migrations.setup'; import { default as mockId } from "../../mock-data/id.mock-data.json"; import { FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO } from "../../../src/dtos/feedback-rating.dto"; import { ErrorStatusCodes } from "../../../src/utils/errorStatusCodes.utils"; +import { DBTestData } from "../../db-data.setup"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ authAdmin: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) })); jest.mock('../../../src/middleware/auth.client.middleware', () => ({ authClient: jest.fn(() => (req: Request, res: Response, next: NextFunction) => { - (req as any).apiClients = { client_id: mockId.clients.valid[0] }; + (req as any).apiClients = { client_id: testValidClientId }; next(); }) })); @@ -21,12 +23,10 @@ jest.mock('../../../src/middleware/maintenance.middleware', () => ({ })); import app from '../../../src/app'; -import { DBTestData } from "../../db-data.setup"; -import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; jest.setTimeout(60000); -const mockValidClientId = mockId.clients.valid[0] as ClientsId; +const testValidClientId = mockId.clients.valid[0] as ClientsId; const testTimestamp = '2025-01-01T14:00:09.000Z'; describe('Integration-tests (repository), priority: entity FeedbackRating', () => { @@ -55,7 +55,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = describe('Testing valid fn calls', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = mockValidClientId; + const testParam_id = testValidClientId; const testResult: FeedbackRatingExtendedResponseDTO | null = { client_id: testParam_id, count: dbData_FeedbackRating[1], @@ -91,7 +91,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = test('Repository process fn findAll(), result: "SUCCESS"', async () => { const testResult: FeedbackRatingExtendedResponseDTO[] | null = [ { - client_id: mockValidClientId, + client_id: testValidClientId, count: dbData_FeedbackRating[1], rating_sum: dbData_FeedbackRating[2], rating_average: 4.2, @@ -118,7 +118,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = describe('Route: GET/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID' as ClientsId; const testError = { type: 'field', value: testParam_id, diff --git a/backend/tests/integration-tests/repositories/feedback.integration.test.ts b/backend/tests/integration-tests/repositories/feedback.integration.test.ts index f59032c..b12b074 100644 --- a/backend/tests/integration-tests/repositories/feedback.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback.integration.test.ts @@ -14,16 +14,12 @@ import router from '../../../src/routes/feedback.route'; import feedbackRatingService from '../../../src/services/feedback-rating.service'; import feedbackRepository from '../../../src/repositories/feedback.repository'; import feedbackService from '../../../src/services/feedback.service'; -import { DBQueryErrorException } from '../../../src/utils/exceptions/db.exception'; +import { DBConstraintErrorException, DBQueryErrorException } from '../../../src/utils/exceptions/db.exception'; import feedbackRatingRepository from '../../../src/repositories/feedback-rating.repository'; import { ClientsId } from '../../../src/repositories/interfaces/clients.entity.interface'; import { UsersId } from '../../../src/repositories/interfaces/users.entity.interface'; import { FeedbackId } from '../../../src/repositories/interfaces/feedback.entity.interface'; -const testValidClientId = mockId.clients.valid[0] as ClientsId; -const testValidUserId = mockId.users.valid[0] as UsersId; -const testTimestamp = '2025-01-01T14:00:08.000Z'; - jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ authAdmin: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) })); @@ -42,7 +38,10 @@ jest.mock('../../../src/middleware/observe.middleware.ts', () => ({ jest.setTimeout(60000); -const mockValidFeedbackId = mockId.feedback.valid[0] as FeedbackId; +const testValidFeedbackId = mockId.feedback.valid[0] as FeedbackId; +const testValidClientId = mockId.clients.valid[0] as ClientsId; +const testValidUserId = mockId.users.valid[0] as UsersId; +const testTimestamp = '2025-01-01T14:00:08.000Z'; describe('Integration-tests (repository), priority: entity Feedback', () => { @@ -75,7 +74,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { const app = createTestApp([], router, apiUrl); - const testParam_id = mockValidFeedbackId; + const testParam_id = testValidFeedbackId; const testResult: FeedbackResponseDTO | null = { feedback_id: testParam_id, client_id: testValidClientId, @@ -197,7 +196,6 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { user_email: email, rating: 2, term_accepted: true, - message: 'test-feedback-message-updated-client[0]' }; // Test data FeedbackRating => position (count): [1], position (rating_sum): [2] const ratingDifference = testParam_dto.rating - dbTestData.getFeedbackInsertSql().values[2]; @@ -208,18 +206,19 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp_update); const testResult: FeedbackResponseDTO = { - feedback_id: mockValidFeedbackId, + feedback_id: testValidFeedbackId, client_id: testValidClientId, user_id: testValidUserId, rating: testParam_dto.rating, rating_average_new: mockResult_average_rating, term_accepted: testParam_dto.term_accepted, - message: testParam_dto.message, last_modified: testTimestamp_update, - created_on: testTimestamp + created_on: testTimestamp, }; await dbTestSetup.addTestData(); + // Update reviewed_on from NULL to value, otherwise WHERE clause sets blocked:true => throw exception. + await feedbackService.updateFeedbackReview(testValidFeedbackId); const testResponse_Feedback = await request(app) .post(`${apiUrl}/create`) .send(testParam_dto); @@ -241,7 +240,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { term_accepted: true, message: 'test-feedback-message-new-client[1]' }; - const mockError = 'Feedback-upsert-mock-error'; + const mockError = 'feedback-upsert-mock-error'; jest.spyOn(CommonUtils, 'logError').mockImplementation(); jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); @@ -283,9 +282,45 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { expect(testResponse_FeedbackRating).toBe(null); }) + test('Repository process fn upsertInTa(), result: Exception on `Blocked`', async () => { + const testParam_dto: FeedbackCreateDTO = { + client_id: mockId.clients.valid[1] as ClientsId, + user_id: testValidUserId, + rating: 3, + term_accepted: true, + message: 'test-feedback-message-new-client[1]' + }; + const testFeedbackId = testValidFeedbackId; + const testResult: FeedbackResponseDTO | null = { + feedback_id: testFeedbackId, + client_id: testValidClientId, + user_id: testValidUserId, + rating: dbData_Feedback[2], + term_accepted: dbData_Feedback[3], + message: dbData_Feedback[4], + last_modified: testTimestamp, + created_on: testTimestamp, + blocked: true + }; + const mockError = 'support-constraint-feedback'; + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + jest.spyOn(feedbackRepository, 'upsertInTa').mockResolvedValue(testResult); + + await dbTestSetup.addTestData(); + await expect(() => feedbackService.createFeedback(testParam_dto)) + .rejects.toThrow(new DBConstraintErrorException(mockError)); + + const testCompareResult = await feedbackService.getFeedbackById(testFeedbackId); + + expect(testCompareResult?.rating).not.toBe(testParam_dto.rating); + expect(testCompareResult?.message).not.toBe(testParam_dto.message); + expect(testCompareResult?.last_modified).toBe(testResult.last_modified); + }) + test('Repository process fn updateReview(), result: "SUCCESS"', async () => { const app = createTestApp([], router, apiUrl); - const testParam_id = mockValidFeedbackId; + const testParam_id = testValidFeedbackId; const mockTimestamp = '2026-01-01T14:00:08.000Z'; // Test without changing created_on this time to see how .spyOn works with multiple calls in process. jest.spyOn(CommonUtils, "getTimestampUTC") @@ -335,7 +370,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { describe('Route: GET/id/:id', () => { test('Params: , validator: fn isInt() by value as string', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-id-number'; const testError = structuredClone(mockError); testError['value'] = testParam_id; @@ -350,7 +385,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { describe('Route: PUT/update/review/:id', () => { test('Params: , validator: fn isInt() by value as string', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-id-number'; const testError = structuredClone(mockError); testError['value'] = testParam_id; @@ -385,7 +420,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: FeedbackFilterDTO = { - client_id: [testValidClientId, 'invalid-id' as ClientsId] + client_id: [testValidClientId, 'invalid-id'] as ClientsId[] } const testError = [{ type: 'field', diff --git a/backend/tests/integration-tests/repositories/meta.integration.test.ts b/backend/tests/integration-tests/repositories/meta.integration.test.ts index 927f915..a7f028c 100644 --- a/backend/tests/integration-tests/repositories/meta.integration.test.ts +++ b/backend/tests/integration-tests/repositories/meta.integration.test.ts @@ -12,6 +12,7 @@ import { CommonExceptionMessage } from '../../../src/utils/enums/common-exceptio import { EnvMode } from '../../../src/utils/enums/env-mode.enum'; import { DemoMode } from '../../../src/utils/enums/demo-mode.enum'; import { default as mockId } from "../../mock-data/id.mock-data.json"; +import { secrets } from '../../../src/utils/secrets.utils'; jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ authAdmin: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) @@ -195,7 +196,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { }) test('Repository process fn updateMaintenance(), result: "SUCCESS"', async () => { - const testParam_id = mockId.meta.valid[0]; + const testParam_id = testValidMetaId; const testParam_data = { maintenance_mode: MaintenanceMode.E013 }; // Mock Utils generated timeStamp for easy comparison. @@ -226,6 +227,8 @@ describe('Integration-tests (repository), priority: entity Meta', () => { const testResult = { app_version: '0.0.1' }; + jest.replaceProperty(secrets, 'APP_VERSION', '0.0.1'); + await dbTestSetup.addTestData(); const testResponse = await request(app) .post('/api/v1/test/demo') @@ -367,7 +370,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { const testedParams = Object.keys(testData) as (keyof typeof testData)[]; test.each(testedParams)('Params: <%s>, validator: fn notEmpty() by undefined', async (invalidParam) => { - const testParam_id = 1; + const testParam_id = testValidMetaId; let mockParam_dto = structuredClone(testData); delete mockParam_dto[invalidParam]; const testError = structuredClone(mockError); @@ -399,7 +402,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { describe('Route: PUT/info/:id', () => { test('Params: , validator: fn requirePayload() by undefined', async () =>{ - const testParam_id = 1; + const testParam_id = testValidMetaId; const testParam_dto = undefined; const testError = structuredClone(mockError); @@ -417,7 +420,7 @@ describe('Integration-tests (repository), priority: entity Meta', () => { describe('Route: PUT/maintenance/:id', () => { test('Params: , validator: fn requirePayload() by undefined', async () =>{ - const testParam_id = mockId.meta.valid[0]; + const testParam_id = testValidMetaId; const testParam_dto = undefined; const testError = structuredClone(mockError); diff --git a/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts b/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts index 0f5fb91..f6a60ec 100644 --- a/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts +++ b/backend/tests/integration-tests/repositories/rate-limits.integration.test.ts @@ -14,12 +14,12 @@ import demoLimitsService from "../../../src/services/demo-limits.service"; import { DemoLimitsCountDTO, DemoLimitsResponseDTO } from "../../../src/dtos/demo-limits.dto"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; -import { RateLimitId } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; -import { DemoLimitId } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; +import { RateLimitsId } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { DemoLimitsId } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; jest.setTimeout(60000); -const testValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitId; +const testValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitsId; const testValidClientId = mockId.clients.valid[0] as ClientsId; const testValidUserId = mockId.users.valid[0] as UsersId; @@ -82,7 +82,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await rateLimitsService.createRateLimit(mockParam_dto); const mockResponse: RateLimitsResponseDTO = { - rate_limit_id: mockId.rate_limits.new[0] as RateLimitId, + rate_limit_id: mockId.rate_limits.new[0] as RateLimitsId, client_id: testValidClientId, user_id: testValidUserId, day: '2025-01-02', @@ -161,7 +161,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await demoLimitsService.createDemoLimit(); const mockResponse: DemoLimitsResponseDTO = { - demo_limit_id: 2 as DemoLimitId, + demo_limit_id: 2 as DemoLimitsId, day: '2025-01-02', count: 1, last_modified: testTimestamp @@ -180,7 +180,7 @@ describe('Integration-tests (repository), priority: entity RateLimits', () => { await dbTestSetup.addTestData(); const testResponse = await demoLimitsService.updateDemoLimit(); const mockResponse: DemoLimitsResponseDTO = { - demo_limit_id: 1 as DemoLimitId, + demo_limit_id: 1 as DemoLimitsId, day: '2025-01-01', count: 2, last_modified: testTimestamp diff --git a/backend/tests/integration-tests/repositories/tickets.integration.test.ts b/backend/tests/integration-tests/repositories/tickets.integration.test.ts index 962cefe..2a1119d 100644 --- a/backend/tests/integration-tests/repositories/tickets.integration.test.ts +++ b/backend/tests/integration-tests/repositories/tickets.integration.test.ts @@ -171,7 +171,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Repository process fn findByFilter(), params: result: "SUCCESS"', async () => { const testParam_dto: TicketsFilterDTO = { - user_id: [testValidUserId, mockId.users.invalid[0] as UsersId], + user_id: [testValidUserId, mockId.users.invalid[0]] as UsersId[], status: TicketStatus.ISSUED }; const testResult: TicketsResponseDTO[] = [ @@ -611,7 +611,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { }) test('Repository process fn delete() with existing data for resource_paths, result: "SUCCESS"', async () => { - const testParam_id = mockId.tickets.valid[0]; + const testParam_id = testValidTicketId; const testResult = true; jest.spyOn(ticketsModel, 'isPermittedToDelete').mockReturnValue(true); @@ -776,7 +776,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { describe('Route: GET/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID'; const testError = structuredClone(mockError); testError['value'] = testParam_id; @@ -791,7 +791,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { describe('Route: PUT/update/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID'; const testParam_dto: TicketsUpdateDTO = { status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, @@ -814,7 +814,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { describe('Route: DELETE/delete/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID'; const testError = structuredClone(mockError); testError['value'] = testParam_id; @@ -844,7 +844,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: TicketsFilterDTO = { - client_id: [mockId.clients.valid[0], 'invalid-id'] as ClientsId[] + client_id: [testValidClientId, 'invalid-id'] as ClientsId[] } const testError = [{ type: 'field', @@ -864,7 +864,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { test('Params: , validator: fn isUUID() by invalid value', async () => { let testParam_dto: TicketsFilterDTO = { - user_id: ['invalid-id-0', mockId.users.valid[0], 'invalid-id-1'] as UsersId[] + user_id: ['invalid-id-0', testValidUserId, 'invalid-id-1'] as UsersId[] } const testError = [ { @@ -1040,10 +1040,10 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { describe('Route: PUT/update/:id', () => { - let testParam_id: string; + let testParam_id: TicketsId; let testData: Partial; beforeEach(() => { - testParam_id = mockId.tickets.invalid[0]; + testParam_id = mockId.tickets.invalid[0] as TicketsId; testData = { status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, @@ -1222,7 +1222,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { describe('Route: PUT/update/:id', () => { test('Params: , validator: fn hasBodyPayload() by undefined', async () =>{ - const testParam_id = mockId.tickets.valid[0]; + const testParam_id = testValidTicketId; const testParam_dto = undefined; jest.spyOn(CommonUtils, 'logError').mockImplementation(); diff --git a/backend/tests/integration-tests/repositories/users.integration.test.ts b/backend/tests/integration-tests/repositories/users.integration.test.ts index 8d19922..2966c96 100644 --- a/backend/tests/integration-tests/repositories/users.integration.test.ts +++ b/backend/tests/integration-tests/repositories/users.integration.test.ts @@ -24,7 +24,7 @@ import app from '../../../src/app'; jest.setTimeout(60000); -const mockValidUserId = mockId.users.valid[0] as UsersId; +const testValidUserId = mockId.users.valid[0] as UsersId; const testTimestamp = '2025-01-01T14:00:03.000Z'; describe('Integration-tests (repository), priority: entity Users', () => { @@ -50,7 +50,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { describe('Testing valid fn calls', () => { test('Repository process fn findById(), result: "SUCCESS"', async () => { - const testParam_id = mockValidUserId; + const testParam_id = testValidUserId; const testResult: UsersResponseDTO = { user_id: testParam_id, email: 'max.mustermann@yqni13.com', @@ -71,7 +71,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { test('Repository process fn findByEmail(), result: "SUCCESS"', async () => { const testParam_email = 'max.mustermann@yqni13.com'; const testResult: UsersResponseDTO = { - user_id: mockValidUserId, + user_id: testValidUserId, email: testParam_email, status: UserStatus.ACTIVE, flag: null, @@ -88,7 +88,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { }) test('Repository process fn findAll(), result: "SUCCESS"', async () => { - const testParam_id = mockValidUserId; + const testParam_id = testValidUserId; const testResult: Users[] = [ { user_id: mockId.users.valid[1] as UsersId, @@ -138,7 +138,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { status: UserStatus.ACTIVE }; const testResult: UsersResponseDTO[] = [{ - user_id: mockValidUserId, + user_id: testValidUserId, email: 'max.mustermann@yqni13.com', status: UserStatus.ACTIVE, flag: null, @@ -163,7 +163,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { last_modified: ['2024-12-01T10:00:00.000Z', '2025-12-01T14:00:00.000Z'] }; const testResult: UsersResponseDTO[] = [{ - user_id: mockValidUserId, + user_id: testValidUserId, email: 'max.mustermann@yqni13.com', status: UserStatus.ACTIVE, flag: null, @@ -210,7 +210,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { }) test('Repository process fn update(), result: "SUCCESS"', async () => { - const testParam_id = mockId.users.valid[0]; + const testParam_id = testValidUserId; const testParam_dto: Partial = { email: 'user@test.com', status: UserStatus.ACTIVE, @@ -236,7 +236,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { }) test('Repository process fn updateFlag(), result: "SUCCESS"', async () => { - const testParam_id = mockValidUserId; + const testParam_id = testValidUserId; const testParam_dto: UsersFlagUpdateDTO = { flag: Flag.WARNING }; @@ -268,7 +268,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { describe('Route: GET/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID'; const testError = { type: 'field', value: testParam_id, @@ -288,7 +288,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { describe('Route: PUT/update/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { - const testParam_id = 'invalid-id'; + const testParam_id = 'invalid-UUID'; const testParam_dto: UsersUpdateDTO = { email: 'new-user@test.com', status: UserStatus.ACTIVE, @@ -335,7 +335,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { const testedParams = Object.keys(testData) as (keyof typeof testData)[]; test.each(testedParams)('Params: <%s>, validator: fn notEmpty() by undefined', async (invalidParam) => { - const testParam_id = mockId.users.valid[0]; + const testParam_id = testValidUserId; let mockParam_dto = structuredClone(testData); delete mockParam_dto[invalidParam]; @@ -412,7 +412,7 @@ describe('Integration-tests (repository), priority: entity Users', () => { describe('Route: PUT/update/:id', () => { test('Params: , validator: fn requirePayload() by undefined', async () =>{ - const testParam_id = mockId.users.valid[0]; + const testParam_id = testValidUserId; const testParam_dto = undefined; const testError = structuredClone(mockError); diff --git a/backend/tests/mock-data/id.mock-data.json b/backend/tests/mock-data/id.mock-data.json index 5065d8d..456f015 100644 --- a/backend/tests/mock-data/id.mock-data.json +++ b/backend/tests/mock-data/id.mock-data.json @@ -1,7 +1,8 @@ { "clients": { "invalid": [ - "c34ad3f7-e263-42a0-bc09-b2d9dd2598c6" + "c34ad3f7-e263-42a0-bc09-b2d9dd2598c6", + "3fd1c826-4c30-42f1-becf-5389575f0d2e" ], "new": [ "56a7a3f5-e2f4-4040-bb00-f194f8853d62" @@ -50,7 +51,8 @@ }, "tickets": { "invalid": [ - "b284d72d-1000-412c-8a7f-bbc9f9b5113d" + "b284d72d-1000-412c-8a7f-bbc9f9b5113d", + "e3dedcd2-bab4-4f05-b592-abdceca708ac" ], "new": [ "6404a514-602b-43de-b4eb-919c83222645" @@ -62,7 +64,8 @@ }, "users": { "invalid": [ - "3c11e85a-5377-4b93-8064-e07e27c643ab" + "3c11e85a-5377-4b93-8064-e07e27c643ab", + "fda3c5e1-3b09-4c49-8f0d-d495a2503a43" ], "new": [ "73b29215-fb40-4b40-904a-2dfe4a448426" diff --git a/backend/tests/unit-tests/middleware/auth.admin.middleware.test.ts b/backend/tests/unit-tests/middleware/auth.admin.middleware.test.ts index 2843c74..dd7ba37 100644 --- a/backend/tests/unit-tests/middleware/auth.admin.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/auth.admin.middleware.test.ts @@ -15,7 +15,7 @@ describe('Unit-tests (middleware), priority: fn authAdmin()', () => { describe('Testing valid fn calls', () => { test('Verfiy admin, params: valid ', async () => { - const mockApiKey = secrets.ADMIN_API.trim(); + const mockApiKey = secrets.ADMIN_KEY.trim(); req.header.mockReturnValue(mockApiKey); // middleware == factory fn returning express fn => fn(req, res, next) diff --git a/backend/tests/unit-tests/middleware/engines.middleware.test.ts b/backend/tests/unit-tests/middleware/engines.middleware.test.ts index 64cea0e..ec9847a 100644 --- a/backend/tests/unit-tests/middleware/engines.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/engines.middleware.test.ts @@ -8,11 +8,11 @@ import demoLimitsService from "../../../src/services/demo-limits.service"; import rateLimitsService from "../../../src/services/rate-limits.service"; import { Violation } from "../../../src/utils/enums/violations.enum"; import { default as mockId } from "../../mock-data/id.mock-data.json"; +import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; +import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; // Ensure correct type by converting secret to number via unary + operator. import { secrets } from "../../../src/utils/secrets.utils"; -import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; -import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; describe('Unit-tests (middleware), priority: class RateLimitsEngine', () => { diff --git a/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts b/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts index 84283fe..3c1b42b 100644 --- a/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts +++ b/backend/tests/unit-tests/middleware/rules/rate-limits.rules.middleware.test.ts @@ -19,10 +19,11 @@ import { MaintenanceMode } from "../../../../src/utils/enums/maintenance-mode.en import { TicketOption } from "../../../../src/utils/enums/ticket-option.enum"; import { ClientsId } from "../../../../src/repositories/interfaces/clients.entity.interface"; import { UsersId } from "../../../../src/repositories/interfaces/users.entity.interface"; +import { TicketsId } from "../../../../src/repositories/interfaces/tickets.entity.interface"; // Ensure correct type by converting secret to number via unary + operator. import { secrets } from "../../../../src/utils/secrets.utils" -import { TicketsId } from "../../../../src/repositories/interfaces/tickets.entity.interface"; +import { MetaId } from "../../../../src/repositories/interfaces/meta.entity.interface"; describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () => { @@ -311,7 +312,7 @@ describe('Unit-tests (middleware), priority: implementation RateLimitsRule', () retryAfter: mockRetryAfter, penalty: { type: Violation.MAINTENANCE_TRAFFIC, - id: 1, + id: mockId.meta.valid[0] as MetaId, penaltyValue: MaintenanceMode.T011 } }; diff --git a/backend/tests/unit-tests/models/clients.models.test.ts b/backend/tests/unit-tests/models/clients.models.test.ts index 00ce481..0abe852 100644 --- a/backend/tests/unit-tests/models/clients.models.test.ts +++ b/backend/tests/unit-tests/models/clients.models.test.ts @@ -9,7 +9,7 @@ import { Clients, ClientsId } from "../../../src/repositories/interfaces/clients import { ApiKeyStatus } from "../../../src/utils/enums/api-key-status.enum"; const mockValidClientId = mockId.clients.valid[0] as ClientsId; -const mockVar_apiKey = clientsModel._generateApiKeyObj(); +const mockVar_apiKey = (clientsModel as any).generateApiKeyObj(); const mockTimestamp = '2025-01-01T14:00:02.000Z'; let mockData: Clients = { client_id: mockValidClientId, @@ -70,7 +70,7 @@ describe('Unit-tests (model), priority: entity Clients', () => { }; jest.spyOn(CommonUtils, "generateUUID").mockReturnValue(mockParam_id); - jest.spyOn(clientsModel, "_generateApiKeyObj").mockReturnValue(mockApiKeyObj); + jest.spyOn(clientsModel as any, "generateApiKeyObj").mockReturnValue(mockApiKeyObj); jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); const testFn = clientsModel.generateClientsCreateObj(mockParam_dto); diff --git a/backend/tests/unit-tests/models/feedback-rating.model.test.ts b/backend/tests/unit-tests/models/feedback-rating.model.test.ts index 521ccbd..6b7e997 100644 --- a/backend/tests/unit-tests/models/feedback-rating.model.test.ts +++ b/backend/tests/unit-tests/models/feedback-rating.model.test.ts @@ -2,12 +2,10 @@ import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRat import feedbackRatingModel from "../../../src/models/feedback-rating.model"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; import { FeedbackRating } from "../../../src/repositories/interfaces/feedback-rating.entity.interface"; -import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import * as mockId from "../../mock-data/id.mock-data.json"; const mockValidClientId = mockId.clients.valid[0] as ClientsId; -const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:09.000Z'; describe('Unit-tests (model), priority: entity FeedbackRating', () => { diff --git a/backend/tests/unit-tests/models/rate-limits.models.test.ts b/backend/tests/unit-tests/models/rate-limits.models.test.ts index e63c192..d44d8ca 100644 --- a/backend/tests/unit-tests/models/rate-limits.models.test.ts +++ b/backend/tests/unit-tests/models/rate-limits.models.test.ts @@ -2,15 +2,15 @@ import { RateLimitsCreateDTO } from "../../../src/dtos/rate-limits.dto"; import demoLimitsModel from "../../../src/models/demo-limits.model"; import rateLimitsModel from "../../../src/models/rate-limits.model"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; -import { DemoLimitId, DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; -import { RateLimitId, RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { DemoLimitsId, DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; +import { RateLimitsId, RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; import { UsersId } from "../../../src/repositories/interfaces/users.entity.interface"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; describe('Unit-tests (model), priority: entity RateLimits', () => { - const testValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitId; + const mockValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitsId; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; @@ -21,7 +21,7 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { test('Sum up count of entries, params: [].length === 2', () => { const mockParam_data: RateLimits[] = [ { - rate_limit_id: testValidRateLimitId, + rate_limit_id: mockValidRateLimitId, client_id: mockValidClientId, user_id: mockValidUserId, day: '2025-01-01', @@ -29,9 +29,9 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { last_modified: '2025-01-01T14:00:05.000Z' }, { - rate_limit_id: mockId.rate_limits.valid[1] as RateLimitId, + rate_limit_id: mockId.rate_limits.valid[1] as RateLimitsId, client_id: mockValidClientId, - user_id: 'another_valid_users_test_id' as UsersId, + user_id: mockId.users.valid[1] as UsersId, day: '2025-01-01', count: 2, last_modified: '2025-01-01T14:00:05.000Z' @@ -47,7 +47,7 @@ describe('Unit-tests (model), priority: entity RateLimits', () => { test('Sum up count of entries, params: [].length === 1', () => { const mockParam_data: RateLimits[] = [ { - rate_limit_id: testValidRateLimitId, + rate_limit_id: mockValidRateLimitId, client_id: mockValidClientId, user_id: mockValidUserId, day: '2025-01-01', @@ -112,7 +112,7 @@ describe('Unit-tests (model), priority: entity DemoLimits', () => { test('Sum up count of entries, params: [].length === 1', () => { const mockParam_data: DemoLimits[] = [ { - demo_limit_id: 1 as DemoLimitId, + demo_limit_id: 1 as DemoLimitsId, day: '2025-01-01', count: 4, last_modified: '2025-01-01T14:00:05.000Z' diff --git a/backend/tests/unit-tests/models/users.models.test.ts b/backend/tests/unit-tests/models/users.models.test.ts index 80cdd07..4476869 100644 --- a/backend/tests/unit-tests/models/users.models.test.ts +++ b/backend/tests/unit-tests/models/users.models.test.ts @@ -15,7 +15,7 @@ describe('Unit-tests (model), priority: entity Users', () => { describe('Testing valid fn calls', () => { test('Generate new user object, params: valid ', () => { - const mockParam_id = mockId.users.valid[0]; + const mockParam_id = mockValidUserId; const mockParam_dto: UsersCreateDTO = { email: 'valid.user@test.com' }; diff --git a/backend/tests/unit-tests/repositories/clients.repositories.test.ts b/backend/tests/unit-tests/repositories/clients.repositories.test.ts index 4b1587d..229e8ef 100644 --- a/backend/tests/unit-tests/repositories/clients.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/clients.repositories.test.ts @@ -217,7 +217,7 @@ describe('Unit-tests (repository), priority: entity Clients', () => { let mockParam_entity: Clients; beforeEach(() => { sql = `INSERT`; - mockVar_apiKey = clientsModel._generateApiKeyObj(); + mockVar_apiKey = (clientsModel as any).generateApiKeyObj(); mockParam_entity = { client_id: mockValidClientId, name: 'valid_clients_test_name', diff --git a/backend/tests/unit-tests/repositories/db.repositories.test.ts b/backend/tests/unit-tests/repositories/db.repositories.test.ts index cb582da..a9d667f 100644 --- a/backend/tests/unit-tests/repositories/db.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/db.repositories.test.ts @@ -42,17 +42,17 @@ describe('Unit-tests (repository), class DBConnection', () => { describe('Testing valid fn calls', () => { - test('Fn _getConnectionString(), environment: development', () => { + test('Fn getConnectionString(), environment: development', () => { const mockParam_env = 'development'; - const testFn = mockDb._getConnectionString(mockParam_env); + const testFn = mockDb.getConnectionString(mockParam_env); const expectUser = 'postgres'; expect(testFn).toContain(expectUser); }) - test('Fn _getConnectionString(), environment: test', () => { + test('Fn getConnectionString(), environment: test', () => { const mockParam_env = 'test'; - const testFn = mockDb._getConnectionString(mockParam_env); + const testFn = mockDb.getConnectionString(mockParam_env); const expectUser = 'testuser'; expect(testFn).toContain(expectUser); diff --git a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts index b84bbd0..182f062 100644 --- a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -148,7 +148,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { test('Return data for multiple existing entries', async () => { const mockData_entry0: FeedbackRating = structuredClone(mockData); const mockData_entry1: FeedbackRating = { - client_id: 'another_clients_test_id' as ClientsId, + client_id: mockId.clients.valid[1] as ClientsId, count: 5, rating_sum: 19, last_modified: mockTimestamp, diff --git a/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts b/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts index 385696d..c738bb5 100644 --- a/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/rate-limits.repositories.test.ts @@ -9,8 +9,8 @@ import { DBQueryErrorException } from "../../../src/utils/exceptions/db.exceptio import * as MockUtils from "../../common.test-utils"; import * as CommonUtils from "../../../src/utils/common.utils"; import { default as mockId } from "../../mock-data/id.mock-data.json"; -import { RateLimitId, RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; -import { DemoLimitId, DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; +import { RateLimitsId, RateLimits } from "../../../src/repositories/interfaces/rate-limits.entity.interface"; +import { DemoLimitsId, DemoLimits } from "../../../src/repositories/interfaces/demo-limits.entity.interface"; import { DemoLimitsCountDTO, DemoLimitsResponseDTO, DemoLimitsUpdateDTO } from "../../../src/dtos/demo-limits.dto"; import demoLimitsRepository from "../../../src/repositories/demo-limits.repository"; import { ClientsId } from "../../../src/repositories/interfaces/clients.entity.interface"; @@ -24,7 +24,7 @@ jest.mock("../../../src/configs/db", () => { } }); -const mockValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitId; +const mockValidRateLimitId = mockId.rate_limits.valid[0] as RateLimitsId; const mockValidClientId = mockId.clients.valid[0] as ClientsId; const mockValidUserId = mockId.users.valid[0] as UsersId; const mockTimestamp = '2025-01-01T14:00:05.000Z'; @@ -76,7 +76,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return entries by day, params: ', async () => { const mockResult: RateLimits[] = [ { - rate_limit_id: mockId.rate_limits.new[0] as RateLimitId, + rate_limit_id: mockId.rate_limits.new[0] as RateLimitsId, client_id: mockValidClientId, user_id: mockValidUserId, day: '2025-02-05', @@ -84,7 +84,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { last_modified: '2025-02-05T04:17:05.000Z' }, { - rate_limit_id: mockId.rate_limits.new[1] as RateLimitId, + rate_limit_id: mockId.rate_limits.new[1] as RateLimitsId, client_id: mockValidClientId, user_id: mockValidUserId, day: '2025-02-05', @@ -267,7 +267,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return entries by day, params: ', async () => { const mockResult: DemoLimits[] = [ { - demo_limit_id: 2 as DemoLimitId, + demo_limit_id: 2 as DemoLimitsId, day: mockParam_dto.day, count: 1, last_modified: '2025-02-05T04:17:05.000Z' @@ -317,7 +317,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return data for created entry, params: >', async () => { const mockResult: DemoLimitsResponseDTO = { - demo_limit_id: 1 as DemoLimitId, + demo_limit_id: 1 as DemoLimitsId, day: mockDate, count: 1, last_modified: mockTimestamp @@ -365,7 +365,7 @@ describe('Unit-tests (repository), priority: entity RateLimits', () => { test('Return data of changed entry, params: valid ', async () => { const mockResult: DemoLimitsResponseDTO | null = { - demo_limit_id: 1 as DemoLimitId, + demo_limit_id: 1 as DemoLimitsId, day: '2025-01-01', count: 1, last_modified: mockTimestamp diff --git a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts index d1123b0..7b7b90c 100644 --- a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts @@ -113,9 +113,9 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { test('Return data for multiple existing entries', async () => { const mockData_entry0: TicketsResponseDTO = structuredClone(mockData); const mockData_entry1: TicketsResponseDTO = { - ticket_id: 'another_valid_tickets_test_id' as TicketsId, - client_id: 'another_valid_clients_test_id' as ClientsId, - user_id: 'another_valid_users_test_id' as UsersId, + ticket_id: mockId.tickets.valid[1] as TicketsId, + client_id: mockId.clients.valid[1] as ClientsId, + user_id: mockId.users.valid[1] as UsersId, status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, title: 'another-test-title', @@ -188,9 +188,9 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { const mockResult: TicketsResponseDTO[] = [ mockData, { - ticket_id: 'another_valid_tickets_id' as TicketsId, + ticket_id: mockId.tickets.valid[1] as TicketsId, client_id: mockValidClientId, - user_id: 'another_valid_users_id' as UsersId, + user_id: mockId.users.valid[1] as UsersId, status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, title: 'valid-test-title', @@ -224,8 +224,8 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { const mockResult: TicketsResponseDTO[] = [ mockData, { - ticket_id: 'another_valid_tickets_id' as TicketsId, - client_id: 'another_valid_client_id' as ClientsId, + ticket_id: mockId.tickets.valid[1] as TicketsId, + client_id: mockId.clients.valid[1] as ClientsId, user_id: mockValidUserId, status: TicketStatus.ACTIVE, option: TicketOption.SUPPORT, @@ -319,7 +319,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { test('Return null for non-existing entry, params: non-existing ', async () => { const mockParam_dto = { - user_id: ['non-existing_users_test_id_0', 'non-existing_users_test_id_1'] as UsersId[] + user_id: [mockId.users.invalid[0], mockId.users.invalid[1]] as UsersId[] }; const mockValues = mockParam_dto.user_id; const mockResult: Tickets[] | null = null; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..d63063c --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,12 @@ +## 🔑 $\textsf{\color{salmon}.env Setup}$ + +### Additional information to set `.env` up correctly : + +| Key | Information | +|-----|-------------| +| SECRET_ADMIN_KEY | free to create yourself | +| SECRET_DB_TEST_.... | use default data for all DB_TEST vars or [see setup](../backend/tests/db-migrations.setup.ts) | +| SECRET_TEST_APIKEY_RAW | use RAW from generateApiKeyObj() [see model](../backend/src/models/clients.model.ts) | +| SECRET_TEST_APIKEY_HASH | use HASH from same function as RAW | +| SECRET_EMAIL_PASS | password from SENDER email | + diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 7bee7a7..1fe9c09 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -1,7 +1,4 @@ -## How to handle DevOps (Docker / Jenkins)? - -### `Docker` - +## 🐋 $\textsf{\color{salmon}How to run app in Docker container}$ Build image, volumes and container from docker-compose.yml of current path and start all (Whenever code-changes are registered, a new image will be built, otherwise the old last image will be restored.): diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 35492e9..096a567 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -94,3 +94,4 @@ Basic description: `...` [specific always sing |------------------------|-----------------------------|------------------------------| | S_ID | Service Identification | 'artdv', 'tava' | | Desc | Description | -- | +| Ta | Transaction | short form for fn naming | diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index cfb2c82..57cf22e 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,11 +1,11 @@ -## How to migrate? +## 🆕 $\textsf{\color{salmon}How to add new migration}$ -Create a new migration file (name scheme: -update.migrations).
This will be saved automatically at [migrations](../../migrations/). +Create a new migration file (ignore or follow name scheme: -update.migrations).
+This will be saved automatically at [migrations](../../migrations/). ```sh -npm run migrate create --.migrations +npx node-pg-migrate create --.migrations ``` - Adapt new migration file on `up` (target changes) and `down` (reset to current state). ```sh const shorthands = undefined; @@ -20,39 +20,34 @@ module.exports = { module.exports = { shorthands, down: (pgm) => { - // reset to current state + // reset to original state } } ``` -Since a central db is in use for this project, the migration can be executed locally. +
+ +## ▶️ $\textsf{\color{salmon}How to run migrations}$ + +### Since a central db is in use for this project, the migration can be executed locally. -Set connection string as environment variable by the following command in powershell
-local database: +
+ +Set connection string as environment variable in powershell for `local database`: ```sh path> $env:DATABASE_URL = "postgresql://:@:/" ``` -hosted database (like Neon): +... or for `hosted database` (like Neon): ```sh path> $env:DATABASE_URL = "postgresql://:@/?sslmode=require" ``` -(direction: 'up' or 'down') -for specific number of migrations (going from youngest to oldest): +To run migration (direction: 'up' or 'down') +specify number of migrations (going from youngest to oldest) or ignore `count` to run all: ```sh -path> npx node-pg-migrate #==> npx node-pg-migrate down 9999 -or -path> npm run migrate- -``` - -or, running all migrations including specified one (up/down, migration file `without .js`): - -```sh -path> $env:DATABASE_URL = -path> npx node-pg-migrate _ -``` - -
+path> npx node-pg-migrate -Check database on correct changes and reset type in `package.json`. \ No newline at end of file +#example (all migrations): npx node-pg-migrate up +#example (precise number): npx node-pg-migrate down 1
+``` \ No newline at end of file From e1543fa3b465ec1026bb909de517c01d0a785caa Mon Sep 17 00:00:00 2001 From: yqni13 Date: Wed, 11 Mar 2026 01:58:16 +0100 Subject: [PATCH 7/7] docs: docker testing information --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 83f9e98..1cd7849 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,8 @@ Install the packages `@jest/globals`, `@types/jest`, `supertest`, `@testcontaine ```sh npm install jest @jest/globals @types/jest supertest @testcontainers/postgresql testcontainers --save-dev ``` -450+ tests exist currently for models, utils, validators and workflows (integration tests) - [see tests](./backend/tests).
+`450+ tests` exist currently for models, utils, validators and workflows (integration tests) - [see tests](./backend/tests).
+Integration-Tests can only run with `active Docker service` due to the ephemeral (temporary) database by testcontainers.
Run tests on local device by including setup for dotenv/config to provide environment variables: ```sh set NODE_ENV=test && jest --setupFiles dotenv/config