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