diff --git a/README.md b/README.md index 48237a6..972a775 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # yqni13 | support -$\texttt{\color{teal}{v1.3.5}}$ +$\texttt{\color{teal}{v1.4.1}}$
@@ -43,6 +43,7 @@ The development process is structured by the TDD (test driven development) princ
🪲 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
@@ -54,7 +55,13 @@ The development process is structured by the TDD (test driven development) princ ### $\textsf{\color{teal}Tickets}$ -Documentation follows with finished feedback handling (task: SUPPORT-64). +Documentation follows with finished refactoring (task: SUPPORT-65). + +
+ +### $\textsf{\color{teal}Feedback/Rating}$ + +Documentation follows with finished refactoring (task: SUPPORT-65).
@@ -118,7 +125,7 @@ Install the packages `@jest/globals`, `@types/jest`, `supertest`, `@testcontaine ```sh npm install jest @jest/globals @types/jest supertest @testcontainers/postgresql testcontainers --save-dev ``` -350+ 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 @@ -153,11 +160,12 @@ Preventing an unwanted merge with unfinished/failed test run, the project is set ### $\textsf{\color{forestgreen}last update:}$ -$\textsf{[v1.3.4\ =>\ {\textbf{\color{brown}v1.3.5}]}}$ app
-$\textsf{[v1.5.3\ =>\ {\textbf{\color{brown}v1.5.4}]}}$ database +$\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{\color{orange}Patch:}$ Updated: - + new migration to add properties 'info_browser', 'info_os' and 'info_device' as well as property 'title' with default value 'support_ticket' to table 'tickets' and increased max length of property 'message' up to 5000 characters - + ticket validation/handling/tests to inlcude new & changed properties + + 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.
diff --git a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js new file mode 100644 index 0000000..193902c --- /dev/null +++ b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js @@ -0,0 +1,109 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +const shorthands = undefined; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ +async function up(pgm) { + pgm.createTable('feedback_entries', { + feedback_id: { + type: 'serial', + primaryKey: true + }, + client_id: { + type: 'uuid', + notNull: true, + references: '"clients"(client_id)', + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + user_id: { + type: 'uuid', + notNull: true, + references: '"users"(user_id)', + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + rating: { + type: 'smallint', + notNull: true, + default: 5 + }, + term_accepted: { + type: 'boolean', + notNull: true, + default: false + }, + message: { + type: 'text', + notNull: false + }, + reviewed_on: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: false + }, + last_modified: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + default: 'NOW()' + }, + created_on: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + default: 'NOW()' + } + }); + pgm.addConstraint( + 'feedback_entries', + 'feedback_entries_unique_clients_users', + { unique: ['client_id', 'user_id'] } + ); + pgm.sql(`ALTER SEQUENCE feedback_entries_feedback_id_seq RESTART WITH 1;`); + pgm.createTable('feedback_ratings', { + client_id: { + type: 'uuid', + primaryKey: true, + references: '"clients"(client_id)', + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + count: { + type: 'integer', + notNull: true, + default: 0 + }, + rating_sum: { + type: 'integer', + notNull: true, + default: 0 + }, + last_modified: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + default: 'NOW()' + }, + created_on: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + default: 'NOW()' + } + }); +}; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ +async function down(pgm) { + pgm.dropTable('feedback_entries', { + ifExists: true, + cascade: true + }); + pgm.dropTable('feedback_ratings', { + ifExists: true, + cascade: true + }); +}; + +module.exports = { shorthands, up, down }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 3d8932c..ab057c9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,6 +1,6 @@ { "name": "support_backend", - "version": "1.3.5", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/backend/package.json b/backend/package.json index 4dc168a..61b0909 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,8 +1,8 @@ { "name": "support_backend", - "version": "1.3.5", + "version": "1.4.1", "appMeta": { - "db_version": "1.5.4", + "db_version": "1.6.0", "docker_image": "yqni13/support", "docker_version": "1.0.6", "jenkins_version": "0.0.0" diff --git a/backend/src/configs/db.ts b/backend/src/configs/db.ts index 2406934..1449b69 100644 --- a/backend/src/configs/db.ts +++ b/backend/src/configs/db.ts @@ -8,6 +8,8 @@ import { logError } from '../utils/common.utils'; // Global setting to parse certain db data to specific types: // 1082: type Date [yyyy-mm-dd] - otherwise Date will be returned as full timestamp + time zone changes pg.types.setTypeParser(1082, (val) => val); +// 1184: tell pg driver how to parse timestamp with time zone +pg.types.setTypeParser(1184, (val) => new Date(val + "Z")); export class DBConnection { private static instance: DBConnection; diff --git a/backend/src/controllers/feedback-rating.controller.ts b/backend/src/controllers/feedback-rating.controller.ts new file mode 100644 index 0000000..ac199f8 --- /dev/null +++ b/backend/src/controllers/feedback-rating.controller.ts @@ -0,0 +1,39 @@ +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"; + +class FeedbackRatingController { + async getExtendedFeedbackRating(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const id = req.params.id; + const response: FeedbackRatingExtendedResponseDTO | null = await feedbackRatingService.getExtendedFeedbackRatingById(id); + res.json(response); + } catch(err: any) { + next(err); + } + } + + async getFeedbackRating(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const client_name = req.params.client_name; + const response: FeedbackRatingResponseDTO | null = await feedbackRatingService.getFeedbackRatingByClientName(client_name); + res.json(response); + } catch(err: any) { + next(err); + } + } + + async getAllFeedbackRatings(req: Request, res: Response, next: NextFunction) { + try { + const response: FeedbackRatingExtendedResponseDTO[] | null = await feedbackRatingService.getAllFeedbackRatings(); + res.json(response); + } catch(err: any) { + next(err); + } + } +} + +export default new FeedbackRatingController(); \ No newline at end of file diff --git a/backend/src/controllers/feedback.controller.ts b/backend/src/controllers/feedback.controller.ts new file mode 100644 index 0000000..f370374 --- /dev/null +++ b/backend/src/controllers/feedback.controller.ts @@ -0,0 +1,56 @@ +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"; + +class FeedbackController { + async getFeedback(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const id = +req.params.id; + const response: FeedbackResponseDTO | null = await feedbackService.getFeedbackById(id); + res.json(response); + } catch(err: any) { + next(err); + } + } + + async postFeedbackEntriesSearch(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const dto: FeedbackFilterDTO = req.body; + const response: FeedbackResponseDTO[] | null = await feedbackService.searchFeedbackEntriesByFilter(dto); + res.json(response); + } catch(err: any) { + next(err); + } + } + + async postFeedback(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const dto: FeedbackCreateDTO = { + ...req.body, + client_id: req.apiClients.client_id, + user_id: req.apiUsers.user_id + }; + const response: FeedbackResponseDTO | null = await feedbackService.createFeedback(dto); + res.json(response); + } catch(err: any) { + next(err); + } + } + + async patchFeedbackReview(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const id = +req.params.id; + const response: FeedbackResponseDTO | null = await feedbackService.updateFeedbackReview(id); + res.json(response); + } catch(err: any) { + next(err); + } + } +} + +export default new FeedbackController(); \ No newline at end of file diff --git a/backend/src/dtos/feedback-rating.dto.ts b/backend/src/dtos/feedback-rating.dto.ts new file mode 100644 index 0000000..4408a4a --- /dev/null +++ b/backend/src/dtos/feedback-rating.dto.ts @@ -0,0 +1,23 @@ +export interface FeedbackRatingCreateDTO { + client_id: string, + count?: number, + rating_sum?: number +} + +export interface FeedbackRatingUpdateDTO { + count?: number, + rating: number, + last_modified?: string +} + +export interface FeedbackRatingResponseDTO { + rating_average: number, +} + +export interface FeedbackRatingExtendedResponseDTO extends FeedbackRatingResponseDTO { + client_id: string, + count: number, + rating_sum: number, + last_modified: string, + created_on: string +} diff --git a/backend/src/dtos/feedback.dto.ts b/backend/src/dtos/feedback.dto.ts new file mode 100644 index 0000000..73e26eb --- /dev/null +++ b/backend/src/dtos/feedback.dto.ts @@ -0,0 +1,43 @@ +export interface FeedbackCreateDTO { + client_id: string, + user_id: string, + rating: number, + term_accepted: boolean, + message?: string, +} + +export interface FeedbackRequestCreateDTO { + user_email: string, + rating: number, + term_accepted: boolean, + message?: string +} + +export interface FeedbackUpdateReviewDTO { + reviewed_on: string, + last_modified: string +} + +export interface FeedbackFilterDTO { + client_id?: string | string[], + user_id?: string | string[], + rating?: number | number[], + term_accepted?: boolean, + reviewed_on?: string | string[], + last_modified?: string | string[], + created_on?: string | string[] +} + +export interface FeedbackResponseDTO { + feedback_id: number, + client_id: string, + user_id: string, + rating: number, + rating_average_new?: number, + rating_old?: number, + term_accepted: boolean, + message?: string, + reviewed_on?: string, + last_modified: string, + created_on: string +} diff --git a/backend/src/loaders/routes.loader.ts b/backend/src/loaders/routes.loader.ts index eca3110..b051f72 100644 --- a/backend/src/loaders/routes.loader.ts +++ b/backend/src/loaders/routes.loader.ts @@ -5,10 +5,14 @@ import metaRouter from '../routes/meta.route'; import ticketsRouter from '../routes/tickets.route'; import usersRouter from '../routes/users.route'; import testRouter from '../routes/test.route'; +import feedbackRouter from '../routes/feedback.route'; +import feedbackRatingRouter from '../routes/feedback-rating.route'; export class RoutesLoader { static initRoutes(app: Application, version: string) { app.use(`/api/${version}/clients`, clientsRouter); + app.use(`/api/${version}/feedback`, feedbackRouter); + app.use(`/api/${version}/feedback-rating`, feedbackRatingRouter); app.use(`/api/${version}/mailing`, mailingRouter); app.use(`/api/${version}/meta`, metaRouter); app.use(`/api/${version}/test`, testRouter); diff --git a/backend/src/middleware/auth.user.middleware.ts b/backend/src/middleware/auth.user.middleware.ts index 1f43a21..93b085b 100644 --- a/backend/src/middleware/auth.user.middleware.ts +++ b/backend/src/middleware/auth.user.middleware.ts @@ -6,6 +6,7 @@ import { UsersCreateDTO } from "../dtos/users.dto"; import { UserStatus } from "../utils/enums/user-status.enum"; import { logError } from "../utils/common.utils"; import { Flag } from "../utils/enums/flag.enum"; +import * as CommonValidators from "../validation/common.validation"; /** * @description Status validation of existing user or create new user by email address. @@ -14,6 +15,7 @@ export function authUser() { return async function(req: Request, res: Response, next: NextFunction) { try { const email = req.body.user_email; + CommonValidators.validateEmail(email); let user: Users | null = await usersService.getUserByEmail(email); if(!user) { const dto: UsersCreateDTO = { diff --git a/backend/src/models/feedback-rating.model.ts b/backend/src/models/feedback-rating.model.ts new file mode 100644 index 0000000..ba74839 --- /dev/null +++ b/backend/src/models/feedback-rating.model.ts @@ -0,0 +1,63 @@ +import { + FeedbackRatingCreateDTO, + FeedbackRatingExtendedResponseDTO, + FeedbackRatingResponseDTO, + FeedbackRatingUpdateDTO +} from "../dtos/feedback-rating.dto"; +import * as CommonUtils from "../utils/common.utils"; +import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; + +class FeedbackRatingModel { + generateFeedbackRatingEntity(dto: FeedbackRatingCreateDTO): FeedbackRating { + const timestamp = CommonUtils.getTimestampUTC(); + return { + ...dto, + count: dto.count ?? 0, + rating_sum: dto.rating_sum ?? 0, + last_modified: timestamp, + created_on: timestamp + }; + } + + mapFeedbackRatingUpdateDTO(dto: FeedbackRatingUpdateDTO): FeedbackRatingUpdateDTO { + const timestamp = CommonUtils.getTimestampUTC(); + // In case of an updated feedback, rating_average gets updated + // => count represents value to be added, not overwritten with. + return { + count: dto.count ?? 0, + rating: dto.rating, + last_modified: timestamp + }; + } + + toFeedbackRatingResponseDTO(entity: FeedbackRating, extended: true): FeedbackRatingExtendedResponseDTO; + toFeedbackRatingResponseDTO(entity: FeedbackRating, extended: false): FeedbackRatingResponseDTO; + + toFeedbackRatingResponseDTO(entity: FeedbackRating, extended: boolean) { + const delta: number = +((entity.rating_sum / entity.count).toFixed(1)); + if(extended) { + return { + client_id: entity.client_id, + count: entity.count, + rating_sum: entity.rating_sum, + rating_average: delta, + last_modified: CommonUtils.getTimestampUTC(new Date(entity.last_modified)), + created_on: CommonUtils.getTimestampUTC(new Date(entity.created_on)) + }; + } + return { rating_average: delta }; + } + + toFeedbackRatingResponseDTOArray(entities: FeedbackRating[], extended: true): FeedbackRatingExtendedResponseDTO[]; + toFeedbackRatingResponseDTOArray(entities: FeedbackRating[], extended: false): FeedbackRatingResponseDTO[]; + + toFeedbackRatingResponseDTOArray(entities: FeedbackRating[], extended: boolean) { + if(extended) { + return entities.map(entity => this.toFeedbackRatingResponseDTO(entity, true)); + } else { + return entities.map(entity => this.toFeedbackRatingResponseDTO(entity, false)); + } + } +} + +export default new FeedbackRatingModel(); \ No newline at end of file diff --git a/backend/src/models/feedback.model.ts b/backend/src/models/feedback.model.ts new file mode 100644 index 0000000..20b6e5c --- /dev/null +++ b/backend/src/models/feedback.model.ts @@ -0,0 +1,39 @@ +import * as CommonUtils from "../utils/common.utils"; +import { FeedbackCreateDTO, FeedbackResponseDTO, FeedbackUpdateReviewDTO } from "../dtos/feedback.dto"; +import { Feedback } from "../repositories/interfaces/feedback.entity.interface"; +import { FeedbackRatingResponseDTO } from "../dtos/feedback-rating.dto"; + +class FeedbackModel { + generateFeedbackEntity(dto: FeedbackCreateDTO): Partial { + const timestamp = CommonUtils.getTimestampUTC() + return { + ...dto, + last_modified: timestamp, + created_on: timestamp + } + } + + generateFeedbackUpdateReviewDTO(): FeedbackUpdateReviewDTO { + const timestamp = CommonUtils.getTimestampUTC(); + return { + reviewed_on: timestamp, + last_modified: timestamp + }; + } + + toFeedbackResponseDTO(entity: Feedback, newRating?: FeedbackRatingResponseDTO): FeedbackResponseDTO { + return { + ...entity, + rating_average_new: newRating?.rating_average, + reviewed_on: entity.reviewed_on ? CommonUtils.getTimestampUTC(new Date(entity.reviewed_on)) : undefined, + last_modified: CommonUtils.getTimestampUTC(new Date(entity.last_modified)), + created_on: CommonUtils.getTimestampUTC(new Date(entity.created_on)) + } + } + + toFeedbackResponseDTOArray(entities: Feedback[]): FeedbackResponseDTO[] { + return entities.map(entity => this.toFeedbackResponseDTO(entity)); + } +} + +export default new FeedbackModel(); \ No newline at end of file diff --git a/backend/src/repositories/feedback-rating.repository.ts b/backend/src/repositories/feedback-rating.repository.ts new file mode 100644 index 0000000..84e9a82 --- /dev/null +++ b/backend/src/repositories/feedback-rating.repository.ts @@ -0,0 +1,107 @@ +import { PoolClient, QueryResult } from "pg"; +import { DBConnection } from "../configs/db"; +import { logError } from "../utils/common.utils"; +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"; + +class FeedbackRatingRepository implements +IFindRepository { + private table: string; + + constructor() { + this.table = 'feedback_ratings'; + } + + async findById(id: string): Promise { + const filterColumn = 'client_id'; + const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; + const value = [id]; + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + const result: QueryResult = await client.query(sql, value); + await db.close(client); + return result.rows[0] ?? null; + } catch(err: any) { + const message = "DB ERROR ON SELECT QUERY"; + const method = "SUPPORT_FeedbackRatingRepository_findById"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + async findByClientName(client_name: string): Promise { + const sql = `SELECT + ${this.table}.* + FROM ${this.table} + JOIN clients ON clients.client_id = ${this.table}.client_id + WHERE clients.name = $1`; + const value = [client_name]; + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + const result: QueryResult = await client.query(sql, value); + await db.close(client); + return result.rows[0] ?? null; + } catch(err: any) { + const message = "DB ERROR ON SELECT QUERY"; + const method = "SUPPORT_FeedbackRatingRepository_findByClientsName"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + async findAll(): Promise { + const orderPrio = "created_on"; + const sql = `SELECT * FROM ${this.table} ORDER BY ${orderPrio} DESC;`; + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + const result: QueryResult = await client.query(sql); + await db.close(client); + return !result.rows[0] || result.rows.length === 0 ? null : result.rows; + } catch(err: any) { + const message = "DB ERROR ON SELECT QUERY"; + const method = "SUPPORT_FeedbackRatingRepository_findAll"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + /** + * @description Repository function to call only when used within a transaction => needs PoolClient as param. + */ + async createInTa(client: PoolClient, entity: FeedbackRating): Promise { + const sql = `INSERT INTO ${this.table} + (client_id, count, rating_sum, last_modified, created_on) + VALUES ($1, $2, $3, $4, $5) + RETURNING *;`; + const values = [entity.client_id, entity.count, entity.rating_sum, entity.last_modified, entity.created_on]; + const result: QueryResult = await client.query(sql, values); + return result.rows[0]; + } + + /** + * @description Repository function to call only when used within a transaction => needs PoolClient as param. + */ + async updateInTa(client: PoolClient, id: string, 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 + WHERE ${filterColumn} = $4 + RETURNING *;`; + const values = [dto.count, dto.rating, dto.last_modified, id]; + const result: QueryResult = await client.query(sql, values); + return result.rows[0] ?? null; + } +} + +export default new FeedbackRatingRepository(); \ No newline at end of file diff --git a/backend/src/repositories/feedback.repository.ts b/backend/src/repositories/feedback.repository.ts new file mode 100644 index 0000000..4b23fc6 --- /dev/null +++ b/backend/src/repositories/feedback.repository.ts @@ -0,0 +1,111 @@ +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 { DBQueryErrorException } from "../utils/exceptions/db.exception"; +import { logError } from "../utils/common.utils"; +import { mapFilteredQueryValues } from "../utils/repository.utils"; + +class FeedbackRepository { + private table: string; + + constructor() { + this.table = 'feedback_entries'; + } + + async findById(id: number): Promise { + const filterColumn = "feedback_id"; + const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; + const value = [id]; + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + const result: QueryResult = await client.query(sql, value); + await db.close(client); + return result.rows[0] ?? null; + } catch(err: any) { + const message = "DB ERROR ON SELECT QUERY"; + const method = "SUPPORT_FeedbackRepository_findById"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + async findByFilter(dto: FeedbackFilterDTO): Promise { + const queryData = mapFilteredQueryValues(dto, this.table); + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + const result: QueryResult = await client.query(queryData.sql, queryData.values); + await db.close(client); + return !result.rows[0] || result.rows.length === 0 ? null : result.rows; + } catch(err: any) { + const message = "DB ERROR ON SELECT QUERY"; + const method = "SUPPORT_FeedbackRepository_findByFilter"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + async updateReview(id: number, dto: FeedbackUpdateReviewDTO): Promise { + const filterColumn = "feedback_id"; + const sql = `UPDATE ${this.table} + SET reviewed_on = $1::timestamp, last_modified = $2::timestamp + WHERE ${filterColumn} = $3 + RETURNING *; + `; + const values = [dto.reviewed_on, dto.last_modified, id]; + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + const result: QueryResult = await client.query(sql, values); + await db.close(client); + return result.rows[0] ?? null; + } catch(err: any) { + const message = "DB ERROR ON UPDATE QUERY"; + const method = "SUPPORT_FeedbackRepository_updateReview"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + /** + * + * @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. + */ + async upsertInTa(client: PoolClient, entity: Partial): Promise { + const sql = ` + WITH pre_update_data AS ( + SELECT rating + FROM ${this.table} + WHERE client_id = $1 AND user_id = $2 + ) + 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; + `; + 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); + return result.rows[0] ?? null; + } +} + +export default new FeedbackRepository(); \ No newline at end of file diff --git a/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts b/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts new file mode 100644 index 0000000..0cffb89 --- /dev/null +++ b/backend/src/repositories/interfaces/feedback-rating.entity.interface.ts @@ -0,0 +1,8 @@ +// Use Clients FK as PK => 1:1 relationship as client is unique for each feedback-rating. +export interface FeedbackRating { + client_id: string, + count: number, + rating_sum: number, + last_modified: string, + created_on: string +} \ No newline at end of file diff --git a/backend/src/repositories/interfaces/feedback.entity.interface.ts b/backend/src/repositories/interfaces/feedback.entity.interface.ts new file mode 100644 index 0000000..33fe571 --- /dev/null +++ b/backend/src/repositories/interfaces/feedback.entity.interface.ts @@ -0,0 +1,11 @@ +export interface Feedback { + feedback_id: number, + client_id: string, + user_id: string, + rating: number, + term_accepted: boolean, + message?: string, + reviewed_on?: string, + last_modified: string, + created_on: string +} \ No newline at end of file diff --git a/backend/src/routes/feedback-rating.route.ts b/backend/src/routes/feedback-rating.route.ts new file mode 100644 index 0000000..57f6282 --- /dev/null +++ b/backend/src/routes/feedback-rating.route.ts @@ -0,0 +1,37 @@ +import { + getFeedbackRatingSchema as getExtendedSchema, + getExtendedFeedbackRatingSchema as getSchema, +} from '../validation/schemata/feedback-rating.schema.validation'; +import { Router } from 'express'; +import { authAdmin } from '../middleware/auth.admin.middleware'; +import { awaitHandlerFactory as factory } from '../middleware/awaitHandlerFactory.middleware'; +import feedbackRatingController from '../controllers/feedback-rating.controller'; +import { authClient } from '../middleware/auth.client.middleware'; +import { maintain } from '../middleware/maintenance.middleware'; + +const router = Router(); + +// findById +router.get( + '/id/:id', + authAdmin(), + getExtendedSchema, + factory(feedbackRatingController.getExtendedFeedbackRating) +); + +// findByClientName +router.get( + '/name/:client_name', + maintain(), authClient(), + getSchema, + factory(feedbackRatingController.getFeedbackRating) +); + +// findAll +router.get( + '/all', + authAdmin(), + factory(feedbackRatingController.getAllFeedbackRatings) +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/feedback.route.ts b/backend/src/routes/feedback.route.ts new file mode 100644 index 0000000..3e64833 --- /dev/null +++ b/backend/src/routes/feedback.route.ts @@ -0,0 +1,51 @@ +import { + getFeedbackSchema as getSchema, + postFeedbackSearchSchema as searchSchema, + postFeedbackSchema as postSchema, + patchFeedbackReviewSchema as patchReviewSchema +} from '../validation/schemata/feedback.schema.validation'; +import { Router } from 'express'; +import { authAdmin } from '../middleware/auth.admin.middleware'; +import { awaitHandlerFactory as factory } from '../middleware/awaitHandlerFactory.middleware'; +import { maintain } from '../middleware/maintenance.middleware'; +import { authClient } from '../middleware/auth.client.middleware'; +import { authUser } from '../middleware/auth.user.middleware'; +import { requirePayload } from '../middleware/require.middleware'; +import { observe } from '../middleware/observe.middleware'; +import feedbackController from '../controllers/feedback.controller'; + +const router = Router(); + +// findById +router.get( + '/id/:id', + authAdmin(), + getSchema, + factory(feedbackController.getFeedback) +) + +// findByFilter +router.post( + '/search', + authAdmin(), + searchSchema, + factory(feedbackController.postFeedbackEntriesSearch) +) + +// create +router.post( + '/create', + maintain(), authClient(), authUser(), requirePayload(), observe(), + postSchema, + factory(feedbackController.postFeedback) +) + +// update (review) +router.put( + '/update/review/:id', + authAdmin(), + patchReviewSchema, + factory(feedbackController.patchFeedbackReview) +) + +export default router; \ No newline at end of file diff --git a/backend/src/services/feedback-rating.service.ts b/backend/src/services/feedback-rating.service.ts new file mode 100644 index 0000000..262a87d --- /dev/null +++ b/backend/src/services/feedback-rating.service.ts @@ -0,0 +1,68 @@ +import { + FeedbackRatingCreateDTO, + FeedbackRatingExtendedResponseDTO, + FeedbackRatingResponseDTO, + FeedbackRatingUpdateDTO +} from "../dtos/feedback-rating.dto"; +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"; + +class FeedbackRatingService { + private timeMapTargets: string[]; + + constructor() { + this.timeMapTargets = ['last_modified', 'created_on']; + } + + async getExtendedFeedbackRatingById(id: string): Promise { + const result: FeedbackRating | null = await feedbackRatingRepository.findById(id); + if(!result) { + return null; + } + const newResult = feedbackRatingModel.toFeedbackRatingResponseDTO(result, true); + return feedbackRatingModel.toFeedbackRatingResponseDTO(newResult, true); + } + + async getFeedbackRatingByClientName(client_name: string): Promise { + const result: FeedbackRating | null = await feedbackRatingRepository.findByClientName(client_name); + if(!result) { + return null; + } + return feedbackRatingModel.toFeedbackRatingResponseDTO(result, false); + } + + async getAllFeedbackRatings(): Promise { + const results: FeedbackRating[] | null = await feedbackRatingRepository.findAll(); + if(!results) { + return null; + } + const newResults = + results.map((result: FeedbackRating) => feedbackRatingModel.toFeedbackRatingResponseDTO(result, true)); + return feedbackRatingModel.toFeedbackRatingResponseDTOArray(newResults, true); + } + + /** + * @description Service function to call only when used within a transaction => needs PoolClient as param. + */ + async createFeedbackRatingInTa(client: PoolClient, dto: FeedbackRatingCreateDTO): Promise { + const entity: FeedbackRating = feedbackRatingModel.generateFeedbackRatingEntity(dto); + const result: FeedbackRating = await feedbackRatingRepository.createInTa(client, entity); + return feedbackRatingModel.toFeedbackRatingResponseDTO(result, true); + } + + /** + * @description Service function to call only when used within a transaction => needs PoolClient as param. + */ + async updateFeedbackRatingInTa(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { + dto = feedbackRatingModel.mapFeedbackRatingUpdateDTO(dto); + const result: FeedbackRating | null = await feedbackRatingRepository.updateInTa(client, id, dto); + if(!result) { + return null; + } + return feedbackRatingModel.toFeedbackRatingResponseDTO(result, false); + } +} + +export default new FeedbackRatingService(); \ No newline at end of file diff --git a/backend/src/services/feedback.service.ts b/backend/src/services/feedback.service.ts new file mode 100644 index 0000000..aca3a74 --- /dev/null +++ b/backend/src/services/feedback.service.ts @@ -0,0 +1,74 @@ +import { + FeedbackCreateDTO, + FeedbackFilterDTO, + FeedbackResponseDTO, + FeedbackUpdateReviewDTO +} from "../dtos/feedback.dto"; +import { FeedbackRatingCreateDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; +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 feedbackRatingService from "./feedback-rating.service"; + +class FeedbackService { + + async getFeedbackById(id: number): Promise { + const result = await feedbackRepository.findById(id); + return !result ? null : feedbackModel.toFeedbackResponseDTO(result); + } + + async searchFeedbackEntriesByFilter(dto: FeedbackFilterDTO): Promise { + const result: Feedback[] | null = await feedbackRepository.findByFilter(dto); + return !result ? null : feedbackModel.toFeedbackResponseDTOArray(result); + } + + /** + * @description Create is used to create new or overwrite existing Feedback with new data inside database transaction => FeedbackRating created/updated in same process. + */ + async createFeedback(dto: FeedbackCreateDTO): Promise { + const message = "DB ERROR ON FEEDBACK/FEEDBACK-RATING TRANSACTION"; + const method = "SUPPORT_FeedbackService_createFeedback"; + + return RepoUtils.asTransaction(message, method, async(client) => { + const entity: Partial = feedbackModel.generateFeedbackEntity(dto); + const result: FeedbackResponseDTO | null = await feedbackRepository.upsertInTa(client, entity); + let dtoUpdateFR: FeedbackRatingUpdateDTO; + if(!result) { + return null; + } 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 }; + } else { + // Existing Feedback was updated => update existing rating_sum (delta). + const rating_delta = result.rating_old ? (dto.rating - result.rating_old) : dto.rating; + dtoUpdateFR = { rating: rating_delta }; + } + dtoUpdateFR = feedbackRatingModel.mapFeedbackRatingUpdateDTO(dtoUpdateFR); + const update: FeedbackRatingResponseDTO | null = + await feedbackRatingService.updateFeedbackRatingInTa(client, result.client_id, dtoUpdateFR); + if(!update) { + const dtoCreateFR: FeedbackRatingCreateDTO = { + client_id: dto.client_id, + count: 1, + rating_sum: dto.rating + }; + await feedbackRatingService.createFeedbackRatingInTa(client, dtoCreateFR); + } + // Use rating from dto if no other ratings for this client exist. + return { + ...result, + rating_average_new: update?.rating_average ?? dto.rating + } + }) + } + + async updateFeedbackReview(id: number): Promise { + const dto: FeedbackUpdateReviewDTO = feedbackModel.generateFeedbackUpdateReviewDTO(); + const result: Feedback | null = await feedbackRepository.updateReview(id, dto); + return !result ? null : feedbackModel.toFeedbackResponseDTO(result); + } +} + +export default new FeedbackService(); \ No newline at end of file diff --git a/backend/src/utils/repository.utils.ts b/backend/src/utils/repository.utils.ts index f28d171..0ca5af9 100644 --- a/backend/src/utils/repository.utils.ts +++ b/backend/src/utils/repository.utils.ts @@ -1,4 +1,8 @@ +import { PoolClient } from "pg"; import { BaseQuery, TimestampFilters } from "../repositories/interfaces/common.repository.interface"; +import { DBConnection } from "../configs/db"; +import { logError } from "./common.utils"; +import { DBQueryErrorException } from "./exceptions/db.exception"; /** * @param data Timestamp values come in array [older, younger]. @@ -22,7 +26,7 @@ export function mapFilteredQueryValues>(dto: T, ta const argGroups: string[] = []; const timestampObj = {}; Object.entries(dto).forEach(([key, content]) => { - if(key !== 'last_modified' && key !== 'created_on') { + if(key !== 'reviewed_on' && key !== 'last_modified' && key !== 'created_on') { const valArr = Array.isArray(content) ? content : [content]; const conditions = valArr.map((value) => { if(value === null) { @@ -54,4 +58,31 @@ export function mapFilteredQueryValues>(dto: T, ta } return { sql: sql + ';', values: values }; +} + +/** + * @description Wrapper to apply logic within transaction environment + logging/exception handling. + */ +export async function asTransaction( + message: string, + method: string, + fn: (client: PoolClient) => Promise +): Promise { + const db = DBConnection.getInstance(); + let client: any; + try { + client = await db.connect(); + await client.query('BEGIN'); + + const result = await fn(client); + + await client.query('COMMIT'); + return result; + } catch(err: any) { + await client.query('ROLLBACK'); + logError(message, method, err); + throw new DBQueryErrorException(err); + } finally { + await db.close(client); + } } \ No newline at end of file diff --git a/backend/src/validation/schemata/feedback-rating.schema.validation.ts b/backend/src/validation/schemata/feedback-rating.schema.validation.ts new file mode 100644 index 0000000..7c073e0 --- /dev/null +++ b/backend/src/validation/schemata/feedback-rating.schema.validation.ts @@ -0,0 +1,15 @@ +import { param, ValidationChain } from 'express-validator'; +import * as CommonValidators from "../common.validation"; + +export const getFeedbackRatingSchema: ValidationChain[] = [ + param('id') + .custom((content: string) => CommonValidators.validateRequestRouteParam(content)) + .bail() + .isUUID(4) + .withMessage('support-invalid-entry#client_id') +]; + +export const getExtendedFeedbackRatingSchema: ValidationChain[] = [ + param('client_name') + .custom((content: string) => CommonValidators.validateRequestRouteParam(content)) +]; \ No newline at end of file diff --git a/backend/src/validation/schemata/feedback.schema.validation.ts b/backend/src/validation/schemata/feedback.schema.validation.ts new file mode 100644 index 0000000..2464aca --- /dev/null +++ b/backend/src/validation/schemata/feedback.schema.validation.ts @@ -0,0 +1,99 @@ +import { body, param, ValidationChain } from 'express-validator'; +import * as CommonValidators from "../common.validation"; +import { CommonExceptionMessage as Message } from '../../utils/enums/common-exception-messages.enum'; + +export const getFeedbackSchema: ValidationChain[] = [ + param('id') + .custom((content: number) => CommonValidators.validateRequestRouteParam(JSON.stringify(content))) + .bail() + .isInt() + .withMessage('support-invalid-entry#feedback_id') +]; + +export const postFeedbackSearchSchema: ValidationChain[] = [ + body('client_id') + .customSanitizer(content => Array.isArray(content) ? content : [content]) + .optional(), + body('client_id.*') + .isUUID(4) + .withMessage('support-invalid-entry#client_id'), + body('user_id') + .customSanitizer(content => Array.isArray(content) ? content : [content]) + .optional(), + body('user_id.*') + .isUUID(4) + .withMessage('support-invalid-entry#user_id'), + body('rating') + .custom((content: any) => { + // Handle empty array as intention to send data => throw require-msg. + if(Array.isArray(content) && content.length === 0) { + throw new Error(Message.REQUIRED); + } + return true; + }) + .bail() + .customSanitizer(content => Array.isArray(content) ? content : [content]) + .optional(), + body('rating.*') + .custom((rating: any) => { + if(isNaN(rating)) { // Can handle empty object but not empty array. + throw new Error('support-invalid-entry#rating'); + } + return true; + }) + .bail() + .isInt({min:1}) + .withMessage('support-invalid-min#rating!1') + .bail() + .isInt({max:5}) + .withMessage('support-invalid-max#rating!5'), + body('term_accepted') + .isBoolean({strict: true}) + .withMessage('support-invalid-entry#term_accepted') + .optional(), + body('reviewed_on') + .custom((timestamps) => CommonValidators.validateTimestampFilter(timestamps)) + .optional(), + body('last_modified') + .custom((timestamps) => CommonValidators.validateTimestampFilter(timestamps)) + .optional(), + body('created_on') + .custom((timestamps) => CommonValidators.validateTimestampFilter(timestamps)) + .optional() +]; + +export const postFeedbackSchema: ValidationChain[] = [ + body('user_email') + .trim() + .notEmpty() + .withMessage(Message.REQUIRED), + // validateEmail() is already executed in auth.user.middleware.ts + body('rating') + .exists({values: 'null'}) + .withMessage(Message.REQUIRED) + .bail() + .isInt({min:1}) + .withMessage('support-invalid-min#rating!1') + .bail() + .isInt({max:5}) + .withMessage('support-invalid-max#rating!5'), + body('term_accepted') + .exists({values: 'null'}) + .withMessage(Message.REQUIRED) + .bail() + .isBoolean({strict: true}) + .withMessage('support-invalid-entry#term_accepted'), + body('message') + .trim() + .isLength({max: 1000}) + .withMessage('support-invalid-max#message!1000') + .optional() +]; + +export const patchFeedbackReviewSchema: ValidationChain[] = [ + param('id') + .custom((content: number) => CommonValidators.validateRequestRouteParam(JSON.stringify(content))) + .bail() + .isInt() + .withMessage('support-invalid-entry#feedback_id') +]; \ No newline at end of file diff --git a/backend/tests/common.test-utils.ts b/backend/tests/common.test-utils.ts index 1d3cc33..d50c945 100644 --- a/backend/tests/common.test-utils.ts +++ b/backend/tests/common.test-utils.ts @@ -1,5 +1,6 @@ import { QueryResult } from "pg"; import { DBConnection } from "../src/configs/db"; +import { NextFunction, Request, Response } from "express"; type MockClient = { query: jest.Mock @@ -34,4 +35,24 @@ export function mapMockDbClient(mockResult: any, mockBoolean: boolean = false, m export function disableConsoleMessages() { jest.spyOn(console, 'info').mockImplementation(); jest.spyOn(console, 'debug').mockImplementation(); +} + +/** + * @description Used in combination with createTestApp() to mock client authentication for flexible testing. + */ +export function injectTestClientId(clientId: string) { + return function (req: Request, res: Response, next: NextFunction) { + (req as any).apiClients = { client_id: clientId }; + next(); + }; +} + +/** + * @description Used in combination with createTestApp() to mock user authentication for flexible testing. + */ +export function injectTestUserId(userId: string) { + return function (req: Request, res: Response, next: NextFunction) { + (req as any).apiUsers = { user_id: userId }; + next(); + }; } \ No newline at end of file diff --git a/backend/tests/db-container.setup.ts b/backend/tests/db-container.setup.ts index 60825dd..107813a 100644 --- a/backend/tests/db-container.setup.ts +++ b/backend/tests/db-container.setup.ts @@ -2,6 +2,7 @@ import { Client } from "pg"; import { StartedTestContainer } from "testcontainers"; import { PostgreSqlContainer } from "@testcontainers/postgresql"; import { DBTestData } from "./db-data.setup"; +import { logError } from "../src/utils/common.utils"; export class DBTestSetup { @@ -63,8 +64,8 @@ export class DBTestSetup { async addTestData() { const dbTestData = DBTestData.getInstance(); - await this.client.query('BEGIN'); try { + await this.client.query('BEGIN'); const metaData = dbTestData.getMetaInsertSql(); await this.client.query(metaData.sql, metaData.values); const clientData = dbTestData.getClientsInsertSql(); @@ -77,12 +78,17 @@ export class DBTestSetup { await this.client.query(rateLimitData.sql, rateLimitData.values); const demoLimitData = dbTestData.getDemoLimitsInsertSql(); await this.client.query(demoLimitData.sql, demoLimitData.values); - const ticketDataNoPaths = dbTestData.getTicketsWithoutPathsInsertSql(); - await this.client.query(ticketDataNoPaths.sql, ticketDataNoPaths.values); + const feedbackData = dbTestData.getFeedbackInsertSql(); + await this.client.query(feedbackData.sql, feedbackData.values); + const feedbackRatingData = dbTestData.getFeedbackRatingInsertSql(); + await this.client.query(feedbackRatingData.sql, feedbackRatingData.values); await this.client.query('COMMIT'); } catch (err: any) { await this.client.query('ROLLBACK'); - throw new Error('TESTCONTAINER ERROR INSERT TEST DATA'); + const message = "TESTCONTAINER ERROR ON INSERT TEST DATA"; + const method = "SUPPORT_DBTestSetup_addTestData"; + logError(message, method, err); + throw new Error(message); } } diff --git a/backend/tests/db-data.setup.ts b/backend/tests/db-data.setup.ts index 20eeab5..ad7e377 100644 --- a/backend/tests/db-data.setup.ts +++ b/backend/tests/db-data.setup.ts @@ -19,7 +19,9 @@ export class DBTestData { users: 'users', tickets: 'tickets', rateLimits: 'rate_limits', - demoLimits: 'demo_limits' + demoLimits: 'demo_limits', + feedback: 'feedback_entries', + feedbackRating: 'feedback_ratings' }; } @@ -47,27 +49,36 @@ export class DBTestData { getClientsInsertSql(): BaseQuery { const sql = `INSERT INTO ${this.tableRecords['clients']} (client_id, name, api_key_hash, status, flag, last_use, last_modified, created_on) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16); `; - const values = [mockId.clients.valid[0], 'TESTCLIENT', secrets.TEST_APIKEY_HASH, ApiKeyStatus.ACTIVE, null, '2025-01-01T14:00:02.000Z', '2025-01-01T14:00:02.000Z', '2025-01-01T14:00:02.000Z']; + const values = [ + mockId.clients.valid[0], 'TESTCLIENT', secrets.TEST_APIKEY_HASH, ApiKeyStatus.ACTIVE, null, '2025-01-01T14:00:02.000Z', '2025-01-01T14:00:02.000Z', '2025-01-01T14:00:02.000Z', + mockId.clients.valid[1], 'ANOTHER-TESTCLIENT', secrets.TEST_APIKEY_HASH, ApiKeyStatus.ACTIVE, null, '2025-01-01T14:00:02.000Z', '2025-01-01T14:00:02.000Z', '2025-01-01T14:00:02.000Z' + ]; return { sql: sql, values: values }; } getUsersInsertSql(): BaseQuery { const sql = `INSERT INTO ${this.tableRecords['users']} (user_id, email, status, flag, last_modified, created_on) - VALUES ($1, $2, $3, $4, $5, $6); + VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12); `; - const values = [mockId.users.valid[0], 'max.mustermann@yqni13.com', UserStatus.ACTIVE, null, '2025-01-01T14:00:03.000Z', '2025-01-01T14:00:03.000Z']; + const values = [ + mockId.users.valid[0], 'max.mustermann@yqni13.com', UserStatus.ACTIVE, null, '2025-01-01T14:00:03.000Z', '2025-01-01T14:00:03.000Z', + mockId.users.valid[1], 'aurora.otsuki@yqni13.com', UserStatus.ACTIVE, null, '2026-01-01T14:00:03.000Z', '2026-01-01T14:00:03.000Z' + ]; return { sql: sql, values: values }; } getTicketsInsertSql(): BaseQuery { const sql = `INSERT INTO ${this.tableRecords['tickets']} (ticket_id, client_id, user_id, status, option, title, message, resource_paths, flag, info_browser, info_os, info_device, last_modified, created_on) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14); + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14), ($15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28); `; - const values = [mockId.tickets.valid[0], mockId.clients.valid[0], mockId.users.valid[0], TicketStatus.ISSUED, TicketOption.SUPPORT, 'test-title', 'test-message', ['test/path/num0', 'test/path/num1'], null, 'Brave 1.87.190 (Official Build) (64-Bit)', 'Windows 11', DeviceOption.COMPUTER, '2025-01-01T14:00:04.000Z', '2025-01-01T14:00:04.000Z']; + const values = [ + mockId.tickets.valid[0], mockId.clients.valid[0], mockId.users.valid[0], TicketStatus.ISSUED, TicketOption.SUPPORT, 'test-title', 'test-message', ['test/path/num0', 'test/path/num1'], null, 'Brave 1.87.190 (Official Build) (64-Bit)', 'Windows 11', DeviceOption.COMPUTER, '2025-01-01T14:00:04.000Z', '2025-01-01T14:00:04.000Z', + mockId.tickets.valid[1], mockId.clients.valid[0], mockId.users.valid[0], TicketStatus.ISSUED, TicketOption.SUPPORT, 'test-title', 'test-message-without-resource_paths', undefined, null, 'Brave 1.87.190 (Official Build) (64-Bit)', 'Android 15', DeviceOption.MOBILE, '2025-01-01T14:00:07.000Z', '2025-01-01T14:00:07.000Z' + ]; return { sql: sql, values: values }; } @@ -89,12 +100,21 @@ export class DBTestData { return { sql: sql, values: values }; } - getTicketsWithoutPathsInsertSql(): BaseQuery { - const sql = `INSERT INTO ${this.tableRecords['tickets']} - (ticket_id, client_id, user_id, status, option, title, message, resource_paths, flag, info_browser, info_os, info_device, last_modified, created_on) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14); + getFeedbackInsertSql(): BaseQuery { + const sql = `INSERT INTO ${this.tableRecords['feedback']} + (client_id, user_id, rating, term_accepted, message, reviewed_on, last_modified, created_on) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + `; + const values = [mockId.clients.valid[0], mockId.users.valid[0], 5, true, 'test-feedback-message', undefined, '2025-01-01T14:00:08.000Z', '2025-01-01T14:00:08.000Z']; + return { sql: sql, values: values }; + } + + getFeedbackRatingInsertSql(): BaseQuery { + const sql = `INSERT INTO ${this.tableRecords['feedbackRating']} + (client_id, count, rating_sum, last_modified, created_on) + VALUES ($1, $2, $3, $4, $5); `; - const values = [mockId.tickets.valid[1], mockId.clients.valid[0], mockId.users.valid[0], TicketStatus.ISSUED, TicketOption.SUPPORT, 'test-title', 'test-message-without-resource_paths', undefined, null, 'Brave 1.87.190 (Official Build) (64-Bit)', 'Android 15', DeviceOption.MOBILE, '2025-01-01T14:00:07.000Z', '2025-01-01T14:00:07.000Z']; + const values = [mockId.clients.valid[0], 16, 67, '2025-01-01T14:00:09.000Z', '2025-01-01T14:00:09.000Z']; return { sql: sql, values: values }; } } \ No newline at end of file diff --git a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts new file mode 100644 index 0000000..56692be --- /dev/null +++ b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts @@ -0,0 +1,137 @@ +import { NextFunction, Request, Response } from "express"; +import * as MockUtils from "../../common.test-utils"; +import request from 'supertest'; +import { DBTestSetup } from "../../db-container.setup"; +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"; + +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] }; + next(); + }) +})); +jest.mock('../../../src/middleware/maintenance.middleware', () => ({ + maintain: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) +})); + +import app from '../../../src/app'; +import { DBTestData } from "../../db-data.setup"; + +jest.setTimeout(60000); + +const testTimestamp = '2025-01-01T14:00:09.000Z'; + +describe('Integration-tests (repository), priority: entity FeedbackRating', () => { + + let dbTestSetup: DBTestSetup; + let dbTestData: DBTestData; + let dbData_FeedbackRating: any[]; + let apiUrl: string; + beforeAll(async () => { + dbTestSetup = new DBTestSetup(); + dbTestData = DBTestData.getInstance(); + dbData_FeedbackRating = dbTestData.getFeedbackRatingInsertSql().values; + await dbTestSetup.init(); + MockUtils.disableConsoleMessages(); // Surpress multiple messages (migration progress etc). Disable to debug. + await runMigrations('feedback-rating.integration.test.ts'); + apiUrl = '/api/v1/feedback-rating'; + }); + beforeEach(async () => { + // Clean tables before each test to fill test data individually. + await dbTestSetup.clearTables(); + }); + afterAll(async () => { + await dbTestSetup.shutdown(); + }); + + describe('Testing valid fn calls', () => { + + test('Repository process fn findById(), result: "SUCCESS"', async () => { + const testParam_id = mockId.clients.valid[0]; + const testResult: FeedbackRatingExtendedResponseDTO | null = { + client_id: testParam_id, + count: dbData_FeedbackRating[1], + rating_sum: dbData_FeedbackRating[2], + rating_average: 4.2, + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/id/${testParam_id}`); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult); + }) + + test('Repository process fn findByClientName(), result: "SUCCESS"', async () => { + const dbData_Clients = dbTestData.getClientsInsertSql().values; + const testParam_client_name = dbData_Clients[1]; + const testResult: FeedbackRatingResponseDTO | null = { + rating_average: 4.2, + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/name/${testParam_client_name}`); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult); + }) + + test('Repository process fn findAll(), result: "SUCCESS"', async () => { + const testResult: FeedbackRatingExtendedResponseDTO[] | null = [ + { + client_id: mockId.clients.valid[0], + count: dbData_FeedbackRating[1], + rating_sum: dbData_FeedbackRating[2], + rating_average: 4.2, + last_modified: testTimestamp, + created_on: testTimestamp + } + ]; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/all`); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult); + }) + + // Create/Update process tested in feedback.integration.test.ts file. + }) + + describe('Testing invalid fn calls', () => { + + describe('All routes, priority: express-validators, location ', () => { + + describe('Route: GET/id/:id', () => { + + test('Params: , validator: fn isUUID() by invalid id', async () => { + const testParam_id = 'invalid-id'; + const testError = { + type: 'field', + value: testParam_id, + msg: 'support-invalid-entry#client_id', + path: 'id', + location: 'params' + }; + + const testResponse = await request(app) + .get(`${apiUrl}/id/${testParam_id}`); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([testError]); + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/integration-tests/repositories/feedback.integration.test.ts b/backend/tests/integration-tests/repositories/feedback.integration.test.ts new file mode 100644 index 0000000..dee45d9 --- /dev/null +++ b/backend/tests/integration-tests/repositories/feedback.integration.test.ts @@ -0,0 +1,723 @@ +import * as CommonUtils from '../../../src/utils/common.utils'; +import * as MockUtils from "../../common.test-utils"; +import { createTestApp } from '../../test-app.setup'; +import request from 'supertest'; +import { DBTestSetup } from "../../db-container.setup"; +import { runMigrations } from '../../db-migrations.setup'; +import { default as mockId } from "../../mock-data/id.mock-data.json"; +import { NextFunction, Request, Response } from "express"; +import { CommonExceptionMessage } from "../../../src/utils/enums/common-exception-messages.enum"; +import { ErrorStatusCodes } from "../../../src/utils/errorStatusCodes.utils"; +import { FeedbackCreateDTO, FeedbackFilterDTO, FeedbackRequestCreateDTO, FeedbackResponseDTO } from '../../../src/dtos/feedback.dto'; +import { DBTestData } from '../../db-data.setup'; +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 feedbackRatingRepository from '../../../src/repositories/feedback-rating.repository'; + +const testValidClientsId = mockId.clients.valid[0]; +const testValidUsersId = mockId.users.valid[0]; +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()) +})); +jest.mock('../../../src/middleware/auth.client.middleware', () => ({ + authClient: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) +})); +jest.mock('../../../src/middleware/auth.user.middleware', () => ({ + authUser: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) +})); +jest.mock('../../../src/middleware/maintenance.middleware', () => ({ + maintain: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) +})); +jest.mock('../../../src/middleware/observe.middleware.ts', () => ({ + observe: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) +})) + +jest.setTimeout(60000); + +describe('Integration-tests (repository), priority: entity Feedback', () => { + + let dbTestSetup: DBTestSetup; + let dbTestData: DBTestData; + let dbData_Feedback: any[]; + let dbData_FeedbackRating: any[]; + let apiUrl: string; + beforeAll(async () => { + dbTestSetup = new DBTestSetup(); + dbTestData = DBTestData.getInstance(); + dbData_Feedback = dbTestData.getFeedbackInsertSql().values; + dbData_FeedbackRating = dbTestData.getFeedbackRatingInsertSql().values; + await dbTestSetup.init(); + MockUtils.disableConsoleMessages(); // Surpress multiple messages (migration progress etc). Disable to debug. + await runMigrations('feedback.integration.test.ts'); + apiUrl = '/api/v1/feedback'; + }); + + beforeEach(async () => { + // Clean tables before each test to fill test data individually. + await dbTestSetup.clearTables(); + }); + + afterAll(async () => { + await dbTestSetup.shutdown(); + }); + + describe('Testing valid fn calls', () => { + + test('Repository process fn findById(), result: "SUCCESS"', async () => { + const app = createTestApp([], router, apiUrl); + const testParam_id = mockId.feedback.valid[0]; + const testResult: FeedbackResponseDTO | null = { + feedback_id: testParam_id, + client_id: testValidClientsId, + user_id: testValidUsersId, + rating: dbData_Feedback[2], + term_accepted: dbData_Feedback[3], + message: dbData_Feedback[4], + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/id/${testParam_id}`); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult); + }) + + test('Repository process fn findByFilter(), result: "SUCCESS"', async () => { + const app = createTestApp([], router, apiUrl); + const testParam_dto: FeedbackFilterDTO = { + term_accepted: false + }; + const testResult: FeedbackResponseDTO[] | null = null; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toBe(testResult); + }) + + 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]) + ], router, apiUrl); + const email = dbTestData.getUsersInsertSql().values[7]; // name, 2nd insert-row + const testParam_dto: FeedbackRequestCreateDTO = { + user_email: email, + rating: 4, + term_accepted: false, + }; + const mockResult_average_rating = + Number(((dbData_FeedbackRating[2] + testParam_dto.rating) / (dbData_FeedbackRating[1] + 1)).toFixed(1)); + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + + const testResult: FeedbackResponseDTO = { + feedback_id: mockId.feedback.new[0], + client_id: testValidClientsId, + user_id: mockId.users.valid[1], + rating: testParam_dto.rating, + rating_average_new: mockResult_average_rating, + term_accepted: testParam_dto.term_accepted, + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult) + }) + + 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]) + ], router, apiUrl); + const email = dbTestData.getUsersInsertSql().values[7]; + const testParam_dto: FeedbackRequestCreateDTO = { + user_email: email, + rating: 5, + term_accepted: true, + message: 'test-feedback-message-new-client[1]' + }; + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + + const testResult: FeedbackResponseDTO = { + feedback_id: mockId.feedback.new[0], + client_id: mockId.clients.valid[1], + user_id: mockId.users.valid[0], + rating: testParam_dto.rating, + rating_average_new: testParam_dto.rating, + term_accepted: testParam_dto.term_accepted, + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse_Feedback = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + const testResponse_FeedbackRating = + await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1]); + + expect(testResponse_Feedback.statusCode).toBe(200); + expect(testResponse_Feedback.body).toMatchObject(testResult); + expect(testResponse_FeedbackRating?.count).toBe(1); + expect(testResponse_FeedbackRating?.rating_sum).toBe(testParam_dto.rating); + }) + + 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]) + ], router, apiUrl); + const email = dbTestData.getUsersInsertSql().values[1]; + const testParam_dto: FeedbackRequestCreateDTO = { + 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]; + const mockResult_rating_sum = dbData_FeedbackRating[2] + ratingDifference; + const mockResult_average_rating = Number((mockResult_rating_sum / (dbData_FeedbackRating[1])).toFixed(1)); + const testTimestamp_update = '2026-01-01T14:00:08.000Z'; + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp_update); + + const testResult: FeedbackResponseDTO = { + feedback_id: mockId.feedback.valid[0], + client_id: mockId.clients.valid[0], + user_id: mockId.users.valid[0], + 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 + }; + + await dbTestSetup.addTestData(); + const testResponse_Feedback = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + const testResponse_FeedbackRating = + await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[0]); + + expect(testResponse_Feedback.statusCode).toBe(200); + expect(testResponse_Feedback.body).toMatchObject(testResult); + expect(testResponse_FeedbackRating?.count).toBe(dbData_FeedbackRating[1]); + expect(testResponse_FeedbackRating?.rating_sum).toBe(mockResult_rating_sum); + }) + + 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], + rating: 5, + term_accepted: true, + message: 'test-feedback-message-new-client[1]' + }; + const mockError = 'Feedback-upsert-mock-error'; + + jest.spyOn(CommonUtils, 'logError').mockImplementation(); + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + jest.spyOn(feedbackRepository, 'upsertInTa').mockRejectedValue(new Error(mockError)); + await dbTestSetup.addTestData(); + + await expect(() => feedbackService.createFeedback(testParam_dto)) + .rejects.toThrow(new DBQueryErrorException(mockError)); + + const testResponse_Feedback = + await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1]}); + + 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], + rating: 5, + term_accepted: true, + message: 'test-feedback-message-new-client[1]' + }; + const mockError = 'FeedbackRating-create-mock-error'; + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + jest.spyOn(feedbackRatingRepository, 'createInTa').mockRejectedValue(new Error(mockError)); + + await dbTestSetup.addTestData(); + const testResponse_Feedback = + await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1]}); + const testResponse_FeedbackRating = + await feedbackRatingService.getExtendedFeedbackRatingById(mockId.clients.valid[1]); + + await expect(() => feedbackService.createFeedback(testParam_dto)) + .rejects.toThrow(new DBQueryErrorException(mockError)); + + expect(testResponse_Feedback).toBe(null); + expect(testResponse_FeedbackRating).toBe(null); + }) + + test('Repository process fn updateReview(), result: "SUCCESS"', async () => { + const app = createTestApp([], router, apiUrl); + const testParam_id = mockId.feedback.valid[0]; + 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") + .mockReturnValueOnce(mockTimestamp) // generateFeedbackUpdateReviewDTO() + .mockReturnValueOnce(mockTimestamp) // toFeedbackResponseDTO() [reviewed_on] + .mockReturnValueOnce(mockTimestamp) // toFeedbackResponseDTO() [last_modified] + .mockReturnValueOnce(testTimestamp);// toFeedbackResponseDTO() [created_on] + + const testResult: FeedbackResponseDTO = { + feedback_id: testParam_id, + client_id: testValidClientsId, + user_id: testValidUsersId, + rating: dbData_Feedback[2], + term_accepted: dbData_Feedback[3], + message: dbData_Feedback[4], + reviewed_on: mockTimestamp, + last_modified: mockTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .put(`${apiUrl}/update/review/${testParam_id}`); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult) + }) + }) + + describe('Testing invalid fn calls', () => { + + describe('All routes, priority: express-validators, location: ', () => { + + let app: any; + let mockError: any; + beforeEach(() => { + app = createTestApp([], router, apiUrl); + mockError = { + type: 'field', + value: '', + msg: 'support-invalid-entry#feedback_id', + path: 'id', + location: 'params' + }; + }) + + describe('Route: GET/id/:id', () => { + + test('Params: , validator: fn isInt() by value as string', async () => { + const testParam_id = 'invalid-id'; + const testError = structuredClone(mockError); + testError['value'] = testParam_id; + + const testResponse = await request(app) + .get(`${apiUrl}/id/${testParam_id}`); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([testError]); + }) + }) + + describe('Route: PUT/update/review/:id', () => { + + test('Params: , validator: fn isInt() by value as string', async () => { + const testParam_id = 'invalid-id'; + const testError = structuredClone(mockError); + testError['value'] = testParam_id; + + const testResponse = await request(app) + .put(`${apiUrl}/update/review/${testParam_id}`); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([testError]); + }) + }) + }) + + describe('All routes, priority: express-validators, location: ', () => { + + let testError: any; + beforeEach(() => { + testError = { + type: 'field', + value: '', + msg: CommonExceptionMessage.REQUIRED, + path: '', + location: 'body' + }; + }); + + describe('Route: POST/search', () => { + + let app: any; + beforeEach(() => { + app = createTestApp([], router, apiUrl); + }) + + test('Params: , validator: fn isUUID() by invalid value', async () => { + let testParam_dto: FeedbackFilterDTO = { + client_id: [mockId.clients.valid[0], 'invalid-id'] + } + const testError = [{ + type: 'field', + value: 'invalid-id', + msg: 'support-invalid-entry#client_id', + path: 'client_id[1]', + location: 'body' + }]; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual(testError); + }) + + 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'] + } + const testError = [ + { + type: 'field', + value: 'invalid-id-0', + msg: 'support-invalid-entry#user_id', + path: 'user_id[0]', + location: 'body' + }, + { + type: 'field', + value: 'invalid-id-1', + msg: 'support-invalid-entry#user_id', + path: 'user_id[2]', + location: 'body' + } + ]; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual(testError); + }) + + test('Params: , validator: fn isEmpty() on empty array', async () => { + const testParam_dto = { rating: [] }; + const mockError = { + type: 'field', + value: [], + msg: CommonExceptionMessage.REQUIRED, + path: 'rating', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn isNaN() by empty object', async () => { + const testParam_dto = { rating: {} }; + const mockError = { + type: 'field', + value: {}, + msg: 'support-invalid-entry#rating', + path: 'rating[0]', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn isNaN() by string value', async () => { + const testParam_dto = { rating: 'test' }; + const mockError = { + type: 'field', + value: 'test', + msg: 'support-invalid-entry#rating', + path: 'rating[0]', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: if(rating < 1) by lower value', async () => { + const testParam_dto = { rating: [2, 0] }; + const mockError = { + type: 'field', + value: 0, + msg: 'support-invalid-min#rating!1', + path: 'rating[1]', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: if(rating > 5) by higher value', async () => { + const testParam_dto = { rating: [5, 4, 7] }; + const mockError = { + type: 'field', + value: 7, + msg: 'support-invalid-max#rating!5', + path: 'rating[2]', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn isBoolean({strict:true}) by string value', async () => { + const testParam_dto = { term_accepted: 'true' }; + const mockError = { + type: 'field', + value: 'true', + msg: 'support-invalid-entry#term_accepted', + path: 'term_accepted', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/search`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + }) + + describe('Route: POST/create', () => { + + let app: any; + let testData: Partial; + beforeEach(() => { + app = createTestApp([ + MockUtils.injectTestClientId(mockId.clients.valid[0]), + MockUtils.injectTestUserId(mockId.users.valid[0]) + ], router, apiUrl); + testData = { + user_email: 'max.muster@test.com', + rating: 4, + term_accepted: false, + message: 'test-message', + } + }) + + const emptyParams = ['rating', 'term_accepted'] as (keyof typeof testData)[]; + + test.each(emptyParams)('Params: <%s>, validator: fn exists() by undefined', async (invalidParam) => { + const testParam_dto = structuredClone(testData); + delete testParam_dto[invalidParam]; + const mockError = structuredClone(testError); + mockError['path'] = invalidParam; + delete mockError['value']; + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn notEmpty() by undefined', async () => { + const testParam_dto: Partial = { + rating: 4, + term_accepted: false, + message: 'test-message', + } + const mockError = structuredClone(testError); + mockError['path'] = 'user_email'; + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toContainEqual(mockError); + }) + + test('Params: , validator: fn isInt({min:1}) by lower value', async () => { + const testParam_dto: Partial = { + user_email: 'max.muster@test.com', + rating: 0, + term_accepted: false, + message: 'test-message', + } + const mockError = { + type: 'field', + value: 0, + msg: 'support-invalid-min#rating!1', + path: 'rating', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn isInt({max:5}) by higher value', async () => { + const testParam_dto: Partial = { + user_email: 'max.muster@test.com', + rating: 6, + term_accepted: false, + message: 'test-message', + } + const mockError = { + type: 'field', + value: 6, + msg: 'support-invalid-max#rating!5', + path: 'rating', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn isBoolean({strict:true}) by number', async () => { + const testParam_dto = { + user_email: 'max.muster@test.com', + rating: 4, + term_accepted: 0, // Would not trigger .isBoolean() without {strict:true}. + message: 'test-message', + }; + const mockError = { + type: 'field', + value: 0, + msg: 'support-invalid-entry#term_accepted', + path: 'term_accepted', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + + test('Params: , validator: fn isLength({max:1000}) by bigger length', async () => { + const mockMessage = String('').padStart(1001, 'test'); + const testParam_dto: FeedbackRequestCreateDTO = { + user_email: 'max.muster@test.com', + rating: 4, + term_accepted: false, + message: mockMessage, + }; + const mockError = { + type: 'field', + value: mockMessage, + msg: 'support-invalid-max#message!1000', + path: 'message', + location: 'body' + }; + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([mockError]); + }) + }) + }) + + describe('All routes, priority: error middleware, location: ', () => { + + let testError: any; + beforeEach(() => { + testError = { + type: 'field', + value: '', + msg: 'support-payload-required', + path: 'req.body', + location: 'body' + }; + }) + + describe('Route: POST/create', () => { + + let app: any; + beforeEach(() => { + app = createTestApp([ + MockUtils.injectTestClientId(mockId.clients.valid[0]), + MockUtils.injectTestUserId(mockId.users.valid[0]) + ], router, apiUrl); + }) + + test('Params: , validator: fn requirePayload() by undefined', async () => { + const testParam_dto = undefined; + + jest.spyOn(CommonUtils, 'logError').mockImplementation(); + + const testResponse = await request(app) + .post(`${apiUrl}/create`) + .send(testParam_dto); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([testError]); + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/integration-tests/repositories/tickets.integration.test.ts b/backend/tests/integration-tests/repositories/tickets.integration.test.ts index 2e9824a..e78d98e 100644 --- a/backend/tests/integration-tests/repositories/tickets.integration.test.ts +++ b/backend/tests/integration-tests/repositories/tickets.integration.test.ts @@ -20,6 +20,7 @@ import { FilesService } from "../../../src/services/files.service"; 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"; const testValidClientsId = mockId.clients.valid[0]; const testValidUsersId = mockId.users.valid[0]; @@ -48,7 +49,6 @@ jest.mock('../../../src/middleware/observe.middleware.ts', () => ({ })) import app from '../../../src/app'; -import { DeviceOption } from "../../../src/utils/enums/device-option.enum"; jest.setTimeout(60000); @@ -402,7 +402,7 @@ describe('Integration-tests (repository), priority: entity Tickets', () => { flag: null, last_modified: testTimestamp, created_on: testTimestamp - } + }; await dbTestSetup.addTestData(); const testResponse = await request(app) diff --git a/backend/tests/integration-tests/repositories/users.integration.test.ts b/backend/tests/integration-tests/repositories/users.integration.test.ts index f379b47..718e666 100644 --- a/backend/tests/integration-tests/repositories/users.integration.test.ts +++ b/backend/tests/integration-tests/repositories/users.integration.test.ts @@ -87,14 +87,24 @@ describe('Integration-tests (repository), priority: entity Users', () => { test('Repository process fn findAll(), result: "SUCCESS"', async () => { const testParam_id = mockId.users.valid[0]; - const testResult: Users[] = [{ - user_id: testParam_id, - email: 'max.mustermann@yqni13.com', - status: UserStatus.ACTIVE, - flag: null, - last_modified: testTimestamp, - created_on: testTimestamp - }]; + const testResult: Users[] = [ + { + user_id: mockId.users.valid[1], + email: 'aurora.otsuki@yqni13.com', + status: UserStatus.ACTIVE, + flag: null, + last_modified: '2026-01-01T14:00:03.000Z', + created_on: '2026-01-01T14:00:03.000Z' + }, + { + user_id: testParam_id, + email: 'max.mustermann@yqni13.com', + status: UserStatus.ACTIVE, + flag: null, + last_modified: testTimestamp, + created_on: testTimestamp + } + ]; await dbTestSetup.addTestData(); const testResponse = await request(app) diff --git a/backend/tests/mock-data/id.mock-data.json b/backend/tests/mock-data/id.mock-data.json index 3f9b765..8947320 100644 --- a/backend/tests/mock-data/id.mock-data.json +++ b/backend/tests/mock-data/id.mock-data.json @@ -7,7 +7,21 @@ "56a7a3f5-e2f4-4040-bb00-f194f8853d62" ], "valid": [ - "9e024539-32e8-4317-8007-84a3956e6b57" + "9e024539-32e8-4317-8007-84a3956e6b57", + "ca2964dd-49b5-49b5-a479-851c2ef6bf44" + ] + }, + "feedback": { + "invalid": [ + 0 + ], + "new": [ + 2, + 4 + ], + "valid": [ + 1, + 3 ] }, "meta": { @@ -48,13 +62,14 @@ }, "users": { "invalid": [ - "" + "3c11e85a-5377-4b93-8064-e07e27c643ab" ], "new": [ "73b29215-fb40-4b40-904a-2dfe4a448426" ], "valid": [ - "87e4d6e3-d678-4de0-8806-e89135cbd38c" + "87e4d6e3-d678-4de0-8806-e89135cbd38c", + "1883ea7c-7f70-4bff-99aa-acf83de3dcab" ] } } \ No newline at end of file diff --git a/backend/tests/test-app.setup.ts b/backend/tests/test-app.setup.ts new file mode 100644 index 0000000..39bd836 --- /dev/null +++ b/backend/tests/test-app.setup.ts @@ -0,0 +1,27 @@ +import bodyParser from "body-parser"; +import express from "express"; +import cors from "cors"; +import { MiddlewareLoader } from "../src/loaders/middleware.loader"; + +/** + * @param middleware Array to inject necessary middleware mocks + * @param router Express router + * @param route Api route (example: /api/v1/entity) + * @returns Mini Express app to simplify testing with injected middleware mocks. + */ +export function createTestApp(middleware: any[], router: any, route: string) { + const app = express(); + + app.use(bodyParser.json()); + app.use(cors()); + app.options("*", cors()); + + if(middleware.length > 0) { + app.use(middleware); + } + + app.use(route, router); + MiddlewareLoader.init(app); + + return app; +} \ No newline at end of file diff --git a/backend/tests/unit-tests/models/feedback-rating.model.test.ts b/backend/tests/unit-tests/models/feedback-rating.model.test.ts new file mode 100644 index 0000000..7d205a6 --- /dev/null +++ b/backend/tests/unit-tests/models/feedback-rating.model.test.ts @@ -0,0 +1,162 @@ +import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../../../src/dtos/feedback-rating.dto"; +import feedbackRatingModel from "../../../src/models/feedback-rating.model"; +import { FeedbackRating } from "../../../src/repositories/interfaces/feedback-rating.entity.interface"; +import * as CommonUtils from "../../../src/utils/common.utils"; +import * as mockId from "../../mock-data/id.mock-data.json"; + +const mockTimestamp = '2025-01-01T14:00:09.000Z'; + +describe('Unit-tests (model), priority: entity FeedbackRating', () => { + + describe('Priority: fn generateFeedbackRatingEntity()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Generate new object, params: are undefined', () => { + const mockParam_dto: FeedbackRatingCreateDTO = { + client_id: mockId.clients.valid[0] + }; + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); + + const testFn = feedbackRatingModel.generateFeedbackRatingEntity(mockParam_dto); + const expectResult: FeedbackRating = { + client_id: mockParam_dto.client_id, + count: 0, + rating_sum: 0, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + + expect(testFn).toEqual(expectResult); + }) + + test('Generate new object, params: have values', () => { + const mockParam_dto: FeedbackRatingCreateDTO = { + client_id: mockId.clients.valid[0], + count: 1, + rating_sum: 5 + }; + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); + + const testFn = feedbackRatingModel.generateFeedbackRatingEntity(mockParam_dto); + const expectResult: FeedbackRating = { + client_id: mockParam_dto.client_id, + count: 1, + rating_sum: 5, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + + expect(testFn).toEqual(expectResult); + }) + }) + }) + + describe('Priority: fn mapFeedbackRatingUpdateDTO()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Map timestamp value to DTO, result: dto FeedbackRatingUpdateDTO', () => { + const mockParam_dto: FeedbackRatingUpdateDTO = { count: 1, rating: 3 }; + const mockNewTimestamp = '2026-01-01T14:00:09.000Z'; + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockNewTimestamp); + + const testFn = feedbackRatingModel.mapFeedbackRatingUpdateDTO(mockParam_dto); + const expectResult: FeedbackRatingUpdateDTO = { + ...mockParam_dto, + last_modified: mockNewTimestamp + }; + + expect(testFn).toEqual(expectResult); + }) + }) + }) + + describe('Priority: fn toFeedbackRatingResponseDTO()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Convert entity to dto + map average rating, result: FeedbackRatingExtendedResponseDTO', () => { + const mockParam_entity: FeedbackRating = { + client_id: mockId.clients.valid[0], + count: 16, + rating_sum: 67, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + const mockParam_extended: boolean = true; + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const testFn = feedbackRatingModel.toFeedbackRatingResponseDTO(mockParam_entity, mockParam_extended); + const expectResult: FeedbackRatingExtendedResponseDTO = { + ...mockParam_entity, + rating_average: 4.2 + }; + + expect(testFn).toEqual(expectResult); + }) + + test('Convert entity to dto + map average rating, result: FeedbackRatingResponseDTO', () => { + const mockParam_entity: FeedbackRating = { + client_id: mockId.clients.valid[0], + count: 16, + rating_sum: 67, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + const mockParam_extended: boolean = false; + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const testFn = feedbackRatingModel.toFeedbackRatingResponseDTO(mockParam_entity, mockParam_extended); + const expectResult: FeedbackRatingResponseDTO = { + rating_average: 4.2 + }; + + expect(testFn).toEqual(expectResult); + }) + }) + }) + + describe('Priority: fn toFeedbackRatingResponseDTOArray()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Convert entity[] to dto[] + map average rating, result: FeedbackRatingExtendedResponseDTO[]', () => { + const mockParam_entities: FeedbackRating[] = [{ + client_id: mockId.clients.valid[0], + count: 16, + rating_sum: 67, + last_modified: mockTimestamp, + created_on: mockTimestamp + }]; + const mockParam_extended: boolean = true; + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const testFn = + feedbackRatingModel.toFeedbackRatingResponseDTOArray(mockParam_entities, mockParam_extended); + const expectResult: FeedbackRatingExtendedResponseDTO[] = [{ + ...mockParam_entities[0], + rating_average: 4.2 + }]; + + expect(testFn).toEqual(expectResult); + }) + + test('Convert entity[] to dto[] + map average rating, result: FeedbackRatingResponseDTO[]', () => { + const mockParam_entities: FeedbackRating[] = [{ + client_id: mockId.clients.valid[0], + count: 16, + rating_sum: 67, + last_modified: mockTimestamp, + created_on: mockTimestamp + }]; + const mockParam_extended: boolean = false; + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const testFn = + feedbackRatingModel.toFeedbackRatingResponseDTOArray(mockParam_entities, mockParam_extended); + const expectResult: FeedbackRatingResponseDTO[] = [{ + rating_average: 4.2 + }]; + + expect(testFn).toEqual(expectResult); + }) + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/unit-tests/models/feedback.model.test.ts b/backend/tests/unit-tests/models/feedback.model.test.ts new file mode 100644 index 0000000..f5b3d5e --- /dev/null +++ b/backend/tests/unit-tests/models/feedback.model.test.ts @@ -0,0 +1,167 @@ +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 { Feedback } from "../../../src/repositories/interfaces/feedback.entity.interface"; +import * as CommonUtils from "../../../src/utils/common.utils"; +import * as mockId from "../../mock-data/id.mock-data.json"; + +const mockTimestamp = '2025-01-01T14:00:08.000Z'; + +describe('Unit-tests (model), priority: entity Feedback', () => { + + let mockEntity: Feedback; + beforeEach(() => { + mockEntity = { + feedback_id: mockId.feedback.valid[0], + client_id: mockId.clients.valid[0], + user_id: mockId.users.valid[0], + rating: 5, + term_accepted: true, + message: 'valid_feedback_test_message', + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + }) + + describe('Priority: fn generateFeedbackEntity()', () => { + + describe('Testing valid fn calls', () => { + + test('Generate new object, params: FeedbackCreateDTO', () => { + const mockParam_dto: FeedbackCreateDTO = { + client_id: mockEntity.client_id, + user_id: mockEntity.user_id, + rating: mockEntity.rating, + term_accepted: mockEntity.term_accepted, + message: mockEntity.message, + }; + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + + const expectResult: Partial = structuredClone(mockEntity); + delete expectResult.feedback_id; + const testFn = feedbackModel.generateFeedbackEntity(mockParam_dto); + + expect(testFn).toMatchObject(expectResult); + }) + }) + }) + + describe('Priority: fn generateFeedbackUpdateReviewDTO()', () => { + + describe('Testing valid fn calls', () => { + + test('Generate new object, params: FeedbackUpdateReviewDTO', () => { + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const expectResult: FeedbackUpdateReviewDTO = { + reviewed_on: mockTimestamp, + last_modified: mockTimestamp + }; + const testFn = feedbackModel.generateFeedbackUpdateReviewDTO(); + + expect(testFn).toMatchObject(expectResult); + }) + }) + }) + + describe('Priority: fn toFeedbackResponseDTO()', () => { + + describe('Testing valid fn calls', () => { + + test('Convert entity to dto, params: Feedback', () => { + const mockParam_entity: Feedback = structuredClone(mockEntity); + mockParam_entity.reviewed_on = mockTimestamp; + + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const expectResult: FeedbackResponseDTO = { + feedback_id: mockEntity.feedback_id, + client_id: mockEntity.client_id, + user_id: mockEntity.user_id, + rating: mockEntity.rating, + term_accepted: mockEntity.term_accepted, + message: mockEntity.message, + reviewed_on: mockTimestamp, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + const testFn = feedbackModel.toFeedbackResponseDTO(mockParam_entity); + + expect(testFn).toMatchObject(expectResult); + }) + + test('Convert entity to dto, params: Feedback/FeedbackRatingResponseDTO', () => { + const mockParam_entity: Feedback = structuredClone(mockEntity); + mockParam_entity.reviewed_on = mockTimestamp; + const mockParam_newRating: FeedbackRatingResponseDTO = { rating_average: 4.2 }; + + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const expectResult: FeedbackResponseDTO = { + feedback_id: mockEntity.feedback_id, + client_id: mockEntity.client_id, + user_id: mockEntity.user_id, + rating: mockEntity.rating, + rating_average_new: mockParam_newRating.rating_average, + term_accepted: mockEntity.term_accepted, + message: mockEntity.message, + reviewed_on: mockTimestamp, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + const testFn = feedbackModel.toFeedbackResponseDTO(mockParam_entity, mockParam_newRating); + + expect(testFn).toMatchObject(expectResult); + }) + }) + }) + + describe('Priority: fn toFeedbackResponseDTOArray()', () => { + + describe('Testing valid fn calls', () => { + + test('Convert entities to array of dto`s, params: Feedback[]', () => { + const originalEntity: Feedback = structuredClone(mockEntity); + 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], + rating: 4, + term_accepted: false, + message: 'another_valid_feedback_test_message', + reviewed_on: mockTimestamp, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + const mockParam_entities: Feedback[] = [originalEntity, newEntity]; + + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const expectResult: FeedbackResponseDTO[] = [ + { + feedback_id: originalEntity.feedback_id, + client_id: originalEntity.client_id, + user_id: originalEntity.user_id, + rating: originalEntity.rating, + term_accepted: originalEntity.term_accepted, + message: originalEntity.message, + reviewed_on: originalEntity.reviewed_on, + last_modified: originalEntity.last_modified, + created_on: originalEntity.created_on + }, + { + feedback_id: newEntity.feedback_id, + client_id: newEntity.client_id, + user_id: newEntity.user_id, + rating: newEntity.rating, + term_accepted: newEntity.term_accepted, + message: newEntity.message, + reviewed_on: newEntity.reviewed_on, + last_modified: newEntity.last_modified, + created_on: newEntity.created_on + } + ]; + const testFn = feedbackModel.toFeedbackResponseDTOArray(mockParam_entities); + + expect(testFn).toMatchObject(expectResult); + }) + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts new file mode 100644 index 0000000..8e1fea1 --- /dev/null +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -0,0 +1,286 @@ +import { FeedbackRating } from "../../../src/repositories/interfaces/feedback-rating.entity.interface"; +import { DBQueryErrorException } from "../../../src/utils/exceptions/db.exception"; +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 feedbackRatingRepository from "../../../src/repositories/feedback-rating.repository"; +import { DBConnection } from "../../../src/configs/db"; +import { FeedbackRatingUpdateDTO } from "../../../src/dtos/feedback-rating.dto"; + +jest.mock("../../../src/configs/db", () => { + return { + DBConnection: { + getInstance: jest.fn() + } + } +}); + +const mockTimestamp = '2025-01-01T14:00:09.000Z'; +const mockData: FeedbackRating = { + client_id: mockId.clients.valid[0], + count: 16, + rating_sum: 67, + last_modified: mockTimestamp, + created_on: mockTimestamp +}; +const expectExceptionResult = DBQueryErrorException; +const mockBoolean = false; + +describe('Unit-tests (repository), priority: entity FeedbackRating', () => { + + describe('Database tests table , priority fn findById()', () => { + + describe('Testing valid fn calls', () => { + + let sql: string; + beforeEach(() => { + sql = `SELECT`; + }); + + test('Return data for existing entry, params: valid ', async () => { + const mockParam_id = mockId.clients.valid[0]; + const mockResult: FeedbackRating = structuredClone(mockData); + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.findById(mockParam_id); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_id]) + ); + }) + + test('Return null for non-existing entry, params: non-existing ', async () => { + const mockParam_id = mockId.clients.invalid[0]; + const mockResult = null; + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.findById(mockParam_id); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_id]) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Throw DBQueryErrorException by catch-block', async () => { + const mockParam_id = mockId.clients.invalid[0]; + const mockErrorMsg = "DB ERROR ON SELECT QUERY"; + const mockResult = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRatingRepository.findById(mockParam_id)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + + describe('Database tests table , priority fn findByClientsName()', () => { + + describe('Testing valid fn calls', () => { + + let sql: string; + beforeEach(() => { + sql = `SELECT`; + }); + + test('Return data for existing entry, params: valid ', async () => { + const mockParam_client_name = 'valid_clients_test_name'; + const mockResult: FeedbackRating = structuredClone(mockData); + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.findByClientName(mockParam_client_name); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_client_name]) + ); + }) + + test('Return null for non-existing entry, params: non-existing ', async () => { + const mockParam_client_name = 'invalid_clients_test_name'; + const mockResult = null; + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.findByClientName(mockParam_client_name); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_client_name]) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Throw DBQueryErrorException by catch-block', async () => { + const mockParam_client_name = 'invalid_clients_test_name'; + const mockErrorMsg = "DB ERROR ON SELECT QUERY"; + const mockResult = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRatingRepository.findByClientName(mockParam_client_name)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + + describe('Database tests table , priority fn findAll()', () => { + + describe('Testing valid fn calls', () => { + + let sql: string; + beforeEach(() => { + sql = 'SELECT'; + }); + + test('Return data for multiple existing entries', async () => { + const mockData_entry0: FeedbackRating = structuredClone(mockData); + const mockData_entry1: FeedbackRating = { + client_id: 'another_clients_test_id', + count: 5, + rating_sum: 19, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + const mockResult: FeedbackRating[] = [mockData_entry0, mockData_entry1]; + + const mockErrorMsg = undefined; + const mockExpectArray = true; + const mockClient = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg, mockExpectArray); + const testFn = await feedbackRatingRepository.findAll(); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql) + ); + }); + + test('Return null for non-existing entry', async () => { + const mockResult: FeedbackRating[] | null = null; + + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.findAll(); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Throw DBQueryErrorException by catch-block', async () => { + const mockErrorMsg = "DB ERROR ON SELECT QUERY"; + const mockResult = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRatingRepository.findAll()) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + + describe('Database tests table , priority fn createInTa()', () => { + + let sql: string; + let mockParam_entity: FeedbackRating; + beforeEach(() => { + sql = `INSERT`; + mockParam_entity = { + client_id: mockId.clients.new[0], + count: 0, + rating_sum: 0, + last_modified: mockTimestamp, + created_on: mockTimestamp + }; + }); + + describe('Testing valid fn calls', () => { + + test('Return data for created entry, params: ', async () => { + const mockValues: any[] = Object.values(mockParam_entity).map(value => value); + const mockResult: FeedbackRating = structuredClone(mockParam_entity); + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.createInTa((mockClient as any), mockParam_entity); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + }) + + // Testing invalid fn call is tested in feedback.integration.test.ts due to transaction structure. + }) + + describe('Database tests table , priority fn updateInTa()', () => { + + let sql: string; + let mockParam_dto: FeedbackRatingUpdateDTO; + beforeEach(() => { + sql = `UPDATE`; + mockParam_dto = { + rating: 5 + }; + }) + + describe('Testing valid fn calls', () => { + + test('Return data of changed entry, params: valid ', async () => { + const mockParam_id = mockData.client_id; + const mockValues: any[] = [mockParam_dto.rating]; + const mockResult: FeedbackRating | null = { + client_id: mockParam_id, + count: mockData.count + 1, + rating_sum: mockData.rating_sum + mockParam_dto.rating, + last_modified: mockTimestamp, + created_on: mockData.created_on + }; + + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.updateInTa((mockClient as any), mockParam_id, mockParam_dto); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + + test('Return null for non-existing entry, params: invalid ', async () => { + const mockParam_id = mockId.clients.invalid[0]; + const mockValues: any[] = [mockParam_dto.rating]; + const mockResult: FeedbackRating | null = null; + + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRatingRepository.updateInTa((mockClient as any), mockParam_id, mockParam_dto); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + }) + + // Testing invalid fn call is tested in feedback.integration.test.ts due to transaction structure. + }) +}) \ No newline at end of file diff --git a/backend/tests/unit-tests/repositories/feedback.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts new file mode 100644 index 0000000..db760fd --- /dev/null +++ b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts @@ -0,0 +1,233 @@ +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 { DBConnection } from "../../../src/configs/db"; +import feedbackRepository from "../../../src/repositories/feedback.repository"; +import { FeedbackFilterDTO, FeedbackUpdateReviewDTO } from "../../../src/dtos/feedback.dto"; + +jest.mock("../../../src/configs/db", () => { + return { + DBConnection: { + getInstance: jest.fn() + } + } +}); + +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], + rating: 5, + term_accepted: true, + message: 'valid_feedback_test_message', + last_modified: mockTimestamp, + created_on: mockTimestamp +}; +const expectExceptionResult = DBQueryErrorException; +const mockBoolean = false; + +describe('Unit-tests (repository), priority: entity Feedback', () => { + + describe('Database tests table , priority fn findById()', () => { + + describe('Testing valid fn calls', () => { + + let sql: string; + beforeEach(() => { + sql = `SELECT`; + }); + + test('Return data for existing entry, params: valid ', async () => { + const mockParam_id = mockId.feedback.valid[0]; + const mockResult: Feedback = structuredClone(mockData); + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRepository.findById(mockParam_id); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_id]) + ); + }) + + test('Return null for non-existing entry, params: non-existing ', async () => { + const mockParam_id = mockId.feedback.invalid[0]; + const mockResult = null; + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRepository.findById(mockParam_id); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_id]) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Throw DBQueryErrorException by catch-block', async () => { + const mockParam_id = mockId.feedback.invalid[0]; + const mockErrorMsg = "DB ERROR ON SELECT QUERY"; + const mockResult = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRepository.findById(mockParam_id)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + + describe('Database tests table , priority fn findByFilter()', () => { + + describe('Testing valid fn calls', () => { + + let sql: string; + beforeEach(() => { + sql = `SELECT`; + }); + + test('Return data for existing entry, params: valid ', async () => { + const mockParam_dto: FeedbackFilterDTO = { user_id: mockData.user_id }; + const mockValues = [mockParam_dto.user_id]; + const mockResult: Feedback[] = [structuredClone(mockData)]; + + const mockErrorMsg = undefined; + const mockExpectArray = true; + const mockClient = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg, mockExpectArray); + const testFn = await feedbackRepository.findByFilter(mockParam_dto); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + + 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 mockValues = mockParam_dto.user_id; + const mockResult: Feedback[] | null = null; + + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRepository.findByFilter(mockParam_dto); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Throw DBQueryErrorException by catch-block', async () => { + const mockErrorMsg = "DB ERROR ON SELECT QUERY"; + const mockResult = null; + const mockParam_dto = {}; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRepository.findByFilter(mockParam_dto)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + + describe('Database tests table , priority fn upsertInTa()', () => { + + describe('Testing valid fn calls', () => { + + test('Return data for created entry, params: Partial', async () => { + const mockParam_entity: Partial = structuredClone(mockData); + delete mockParam_entity.feedback_id; + const mockValues: any[] = Object.values(mockParam_entity).map(value => value); + const sql = `INSERT`; + + const mockResult: Feedback = structuredClone(mockData); + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRepository.upsertInTa((mockClient as any), mockParam_entity); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + }) + + // Testing invalid fn call is tested in feedback.integration.test.ts due to transaction structure. + }) + + describe('Database tests table , priority fn updateReview()', () => { + + let sql: string; + let mockParam_dto: FeedbackUpdateReviewDTO; + beforeEach(() => { + sql = `UPDATE`; + mockParam_dto = { + reviewed_on: mockTimestamp, + last_modified: mockTimestamp + }; + }) + + describe('Testing valid fn calls', () => { + + test('Return data of changed entry, params: valid ', async () => { + const mockParam_id = mockId.feedback.valid[0]; + const mockValues: any[] = [mockParam_dto.reviewed_on, mockParam_dto.last_modified]; + const mockResult: Feedback | null = structuredClone(mockData); + + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRepository.updateReview(mockParam_id, mockParam_dto); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + + test('Return data of changed entry, params: invalid ', async () => { + const mockParam_id = mockId.feedback.invalid[0]; + const mockValues: any[] = [mockParam_dto.reviewed_on, mockParam_dto.last_modified]; + const mockResult: Feedback | null = null; + + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await feedbackRepository.updateReview(mockParam_id, mockParam_dto); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining(mockValues) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('', async () => { + const mockParam_id = mockId.feedback.invalid[0]; + const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; + const mockResult = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRepository.updateReview(mockParam_id, mockParam_dto)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts index b45a258..e8465c8 100644 --- a/backend/tests/unit-tests/repositories/tickets.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/tickets.repositories.test.ts @@ -419,7 +419,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { describe('Testing valid fn calls', () => { - test('Return data of changed entry, params: valid ', async () => { + test('Return data of changed entry, params: valid ', async () => { const mockParam_id = mockData.user_id; const mockValues: any[] = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); @@ -437,7 +437,7 @@ describe('Unit-tests (repository), priority: entity Tickets', () => { ); }) - test('Return null for non-existing entry, params: invalid ', async () => { + test('Return null for non-existing entry, params: invalid ', async () => { const mockParam_id = mockId.tickets.invalid[0]; const mockValues: any[] = Object.values(mockParam_dto).map(value => value); mockValues.push(mockParam_id); diff --git a/backend/tests/unit-tests/utils/repository.utils.test.ts b/backend/tests/unit-tests/utils/repository.utils.test.ts index f2544ef..53c873e 100644 --- a/backend/tests/unit-tests/utils/repository.utils.test.ts +++ b/backend/tests/unit-tests/utils/repository.utils.test.ts @@ -1,8 +1,11 @@ +import { DBConnection } from "../../../src/configs/db"; import { TicketsFilterDTO } from "../../../src/dtos/tickets.dto"; import { TimestampFilters } from "../../../src/repositories/interfaces/common.repository.interface" import { Flag } from "../../../src/utils/enums/flag.enum"; import { TicketStatus } from "../../../src/utils/enums/ticket-status.enum"; +import { DBQueryErrorException } from "../../../src/utils/exceptions/db.exception"; import * as RepoUtils from "../../../src/utils/repository.utils"; +import * as CommonUtils from "../../../src/utils/common.utils"; describe('Unit-tests (utils), priority: synonym RepoUtils', () => { @@ -103,5 +106,57 @@ describe('Unit-tests (utils), priority: synonym RepoUtils', () => { expect(testFn).toMatchObject(mockResult); }) + + test('Fn asTransaction(), params: for successful transaction', async () => { + const mockParam_message = 'DB ERROR ON REPO-UTILS TRANSACTION'; + const mockParam_method = 'SUPPORT_RepoUtils_asTransaction'; + const mockQuery = jest.fn(); + const mockClient = { query: mockQuery }; + const mockDbClose = jest.fn(); + const mockDbConnect = jest.fn().mockResolvedValue(mockClient); + + jest.spyOn(DBConnection, 'getInstance').mockReturnValue({ + connect: mockDbConnect, + close: mockDbClose + } as any); + + const testResult = 'test-logic-within-transaction'; + const mockFn = jest.fn().mockResolvedValue(testResult); + const testFn = await RepoUtils.asTransaction(mockParam_message, mockParam_method, mockFn); + + expect(mockDbConnect).toHaveBeenCalled(); + expect(mockQuery).toHaveBeenNthCalledWith(1, 'BEGIN'); + expect(mockQuery).toHaveBeenNthCalledWith(2, 'COMMIT'); + expect(mockFn).toHaveBeenCalledWith(mockClient); + expect(mockDbClose).toHaveBeenCalledWith(mockClient); + expect(testFn).toBe(testResult); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Fn asTransaction(), params: for failed transaction => ROLLBACK', async () => { + const mockParam_message = 'DB ERROR ON REPO-UTILS TRANSACTION'; + const mockParam_method = 'SUPPORT_RepoUtils_asTransaction'; + const mockQuery = jest.fn(); + const mockClient = { query: mockQuery }; + const mockDbClose = jest.fn(); + const mockDbConnect = jest.fn().mockResolvedValue(mockClient); + + jest.spyOn(DBConnection, 'getInstance').mockReturnValue({ + connect: mockDbConnect, + close: mockDbClose + } as any); + jest.spyOn(CommonUtils, 'logError').mockImplementation(); + + const mockFn = jest.fn().mockRejectedValue(new Error('test-error-rollback')); + + await expect(RepoUtils.asTransaction( + mockParam_message, mockParam_method, mockFn + )).rejects.toThrow(DBQueryErrorException); + expect(mockQuery).toHaveBeenNthCalledWith(1, 'BEGIN'); + expect(mockQuery).toHaveBeenNthCalledWith(2, 'ROLLBACK'); + expect(mockDbClose).toHaveBeenCalledWith(mockClient); + }) }) }) \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 548b505..a020ec8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,10 +4,21 @@
+### $\textsf{\color{skyblue}2026/03/07}$ + +$\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{\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. + +
+ ### $\textsf{\color{skyblue}2026/02/26}$ -$\textsf{[v1.3.4\ =>\ {\textbf{\color{brown}v1.3.5}]}}$ app
-$\textsf{[v1.5.3\ =>\ {\textbf{\color{brown}v1.5.4}]}}$ database +$\textsf{[v1.3.4\ =>\ v1.3.5]}$ app
+$\textsf{[v1.5.3\ =>\ v1.5.4]}$ database - $\textsf{\color{orange}Patch:}$ Updated: + new migration to add properties 'info_browser', 'info_os' and 'info_device' as well as property 'title' with default value 'support_ticket' to table 'tickets' and increased max length of property 'message' up to 5000 characters + ticket validation/handling/tests to inlcude new & changed properties