From 9bafc63513375a718faacfcb7877bed68815faac Mon Sep 17 00:00:00 2001 From: yqni13 Date: Sat, 28 Feb 2026 14:14:10 +0100 Subject: [PATCH 1/8] feat: add clients repository fn findByName() --- .../src/repositories/clients.repository.ts | 20 +++++++ backend/src/services/clients.service.ts | 5 ++ .../repositories/clients.integration.test.ts | 19 +++++++ .../repositories/clients.repositories.test.ts | 53 +++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/backend/src/repositories/clients.repository.ts b/backend/src/repositories/clients.repository.ts index 26adc3e..cdf0ffe 100644 --- a/backend/src/repositories/clients.repository.ts +++ b/backend/src/repositories/clients.repository.ts @@ -33,6 +33,26 @@ class ClientsRepository implements ICreateRepository, IUpdateFlagReposi } } + async findByName(name: string): Promise { + const filterColumn = 'name'; + const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; + const value = [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_ClientsRepository_findByName"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + async findByActiveKey(hash: string): Promise { const sql = `SELECT * FROM ${this.table} WHERE api_key_hash = $1 AND status = $2;`; const values = [hash, ApiKeyStatus.ACTIVE]; diff --git a/backend/src/services/clients.service.ts b/backend/src/services/clients.service.ts index 4876f8c..c2c1f4f 100644 --- a/backend/src/services/clients.service.ts +++ b/backend/src/services/clients.service.ts @@ -29,6 +29,11 @@ class ClientsService { return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } + async getClientByName(name: string): Promise { + const result = await clientsRepository.findByName(name); + return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); + } + /** * @description Usage for apikey authentication in auth.middleware.ts. */ diff --git a/backend/tests/integration-tests/repositories/clients.integration.test.ts b/backend/tests/integration-tests/repositories/clients.integration.test.ts index a868924..ff303a2 100644 --- a/backend/tests/integration-tests/repositories/clients.integration.test.ts +++ b/backend/tests/integration-tests/repositories/clients.integration.test.ts @@ -76,6 +76,25 @@ describe('Integration-tests (repository), priority: entity Clients', () => { expect(testResponse).toMatchObject(testResult); }) + + test('Repository process fn findByName(), result: "SUCCESS"', async () => { + const mockParam_name = 'TESTCLIENT'; + const testResult: ClientsExistResponseDTO | null = { + client_id: mockId.clients.valid[0], + name: 'TESTCLIENT', + api_key_hash: secrets.TEST_APIKEY_HASH, + status: ApiKeyStatus.ACTIVE, + flag: null, + last_use: testTimestamp, + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await clientsService.getClientByName(mockParam_name); + + expect(testResponse).toMatchObject(testResult); + }) test('Repository process fn findStatusByName(), result: "SUCCESS"', async () => { const testParam_name = 'TESTCLIENT'; diff --git a/backend/tests/unit-tests/repositories/clients.repositories.test.ts b/backend/tests/unit-tests/repositories/clients.repositories.test.ts index 41d4925..79fe842 100644 --- a/backend/tests/unit-tests/repositories/clients.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/clients.repositories.test.ts @@ -96,6 +96,59 @@ describe('Unit-tests (repository), priority: entity Clients', () => { }) }) + describe('Database tests table , priority: fn findByName()', () => { + + describe('Testing valid fn calls', () => { + + let sql: string; + beforeEach(() => { + sql = `SELECT`; + }); + + test('Return data for existing entry, params: valid ', async () => { + const mockParam_name = 'valid_clients_test_name'; + const mockResult: Clients | null = structuredClone(mockData); + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await clientsRepository.findByName(mockParam_name); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_name]) + ); + }) + + test('Return null for non-existing entry, params: invalid ', async () => { + const mockParam_name = 'invalid_clients_test_name'; + const mockResult: Clients | null = null; + const mockClient = MockUtils.mapMockDbClient(mockResult); + const testFn = await clientsRepository.findByName(mockParam_name); + + expect(testFn).toEqual(mockResult); + expect(DBConnection.getInstance).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining(sql), + expect.arrayContaining([mockParam_name]) + ); + }) + }) + + describe('Testing invalid fn calls', () => { + + test('Throw DBQueryErrorException by catch-block', async () => { + const mockParam_name = 'invalid_clients_test_name'; + const mockErrorMsg = "DB ERROR ON SELECT QUERY"; + const mockResult: Clients | null = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => clientsRepository.findByName(mockParam_name)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + describe('Database tests table , priority: fn findByActiveKey()', () => { describe('Testing valid fn calls', () => { From 3cfcf3f2736e2be95d9456f84634dae9e0c45ccc Mon Sep 17 00:00:00 2001 From: yqni13 Date: Sun, 1 Mar 2026 00:28:29 +0100 Subject: [PATCH 2/8] test: feedback_ratings tests/interfaces/migration --- ...6_12-feedback-entries-create.migrations.js | 99 ++++++ backend/src/dtos/feedback-rating.dto.ts | 20 ++ backend/src/loaders/routes.loader.ts | 4 + .../feedback-rating.entity.interface.ts | 8 + .../feedback-rating.schema.validation.ts | 26 ++ backend/tests/db-container.setup.ts | 12 +- backend/tests/db-data.setup.ts | 31 +- .../feedback-rating.integration.test.ts | 243 ++++++++++++++ .../repositories/tickets.integration.test.ts | 4 +- backend/tests/mock-data/id.mock-data.json | 16 +- .../models/feedback-rating.model.test.ts | 97 ++++++ .../feedback-rating.repositories.test.ts | 309 ++++++++++++++++++ .../repositories/tickets.repositories.test.ts | 4 +- 13 files changed, 866 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/1772192517446_12-feedback-entries-create.migrations.js create mode 100644 backend/src/dtos/feedback-rating.dto.ts create mode 100644 backend/src/repositories/interfaces/feedback-rating.entity.interface.ts create mode 100644 backend/src/validation/schemata/feedback-rating.schema.validation.ts create mode 100644 backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts create mode 100644 backend/tests/unit-tests/models/feedback-rating.model.test.ts create mode 100644 backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts 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..5c2f862 --- /dev/null +++ b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js @@ -0,0 +1,99 @@ +/** + * @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: 0 + }, + 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 + }, + created_on: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + } + }); + 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 + }, + created_on: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + } + }); +}; + +/** + * @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/src/dtos/feedback-rating.dto.ts b/backend/src/dtos/feedback-rating.dto.ts new file mode 100644 index 0000000..e43aa6c --- /dev/null +++ b/backend/src/dtos/feedback-rating.dto.ts @@ -0,0 +1,20 @@ +export interface FeedbackRatingCreateDTO { + client_id: string +} + +export interface FeedbackRatingUpdateDTO { + 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/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/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/validation/schemata/feedback-rating.schema.validation.ts b/backend/src/validation/schemata/feedback-rating.schema.validation.ts new file mode 100644 index 0000000..27523cc --- /dev/null +++ b/backend/src/validation/schemata/feedback-rating.schema.validation.ts @@ -0,0 +1,26 @@ +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 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)) +]; + +export const postFeedbackRatingSchema: ValidationChain[] = [ + body('client_id') + .trim() + .notEmpty() + .withMessage(Message.REQUIRED) + .bail() + .isUUID(4) + .withMessage('support-invalid-entry#client_id') +]; \ No newline at end of file diff --git a/backend/tests/db-container.setup.ts b/backend/tests/db-container.setup.ts index 60825dd..2adcf80 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 { @@ -69,6 +70,8 @@ export class DBTestSetup { await this.client.query(metaData.sql, metaData.values); const clientData = dbTestData.getClientsInsertSql(); await this.client.query(clientData.sql, clientData.values); + const clientAdditionalData = dbTestData.getAdditionalClientsInsertSql(); + await this.client.query(clientAdditionalData.sql, clientAdditionalData.values); const userData = dbTestData.getUsersInsertSql(); await this.client.query(userData.sql, userData.values); const ticketData = dbTestData.getTicketsInsertSql(); @@ -79,10 +82,17 @@ export class DBTestSetup { 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..cba19e0 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' }; } @@ -53,6 +55,15 @@ export class DBTestData { return { sql: sql, values: values }; } + getAdditionalClientsInsertSql(): 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); + `; + const values = [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) @@ -97,4 +108,22 @@ export class DBTestData { 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']; return { sql: sql, values: values }; } + + getFeedbackInsertSql(): BaseQuery { + const sql = `INSERT INTO ${this.tableRecords['feedback']} + (feedback_id, client_id, user_id, rating, term_accepted, message, reviewed_on, last_modified, created_on) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); + `; + const values = [mockId.feedback.valid[0], mockId.clients.valid[0], mockId.users.valid[0], 5, true, 'test-feedback-message', '2025-01-01T14:00:08.000Z', '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.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..adcde72 --- /dev/null +++ b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts @@ -0,0 +1,243 @@ +import { NextFunction, Request, Response } from "express"; +import * as CommonUtils from '../../../src/utils/common.utils'; +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 { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../../../src/dtos/feedback-rating.dto"; +import { FeedbackRating } from "../../../src/repositories/interfaces/feedback-rating.entity.interface"; +import feedbackRatingService from "../../../src/services/feedback-rating.service"; +import { ErrorStatusCodes } from "../../../src/utils/errorStatusCodes.utils"; +import { CommonExceptionMessage } from "../../../src/utils/enums/common-exception-messages.enum"; + + +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'; + +jest.setTimeout(60000); + +const testTimestamp = '2025-01-01T14:00:09.000Z'; + +describe('Integration-tests (repository), priority: entity FeedbackRating', () => { + + let dbTestSetup: DBTestSetup; + let apiUrl: string; + beforeAll(async () => { + dbTestSetup = new DBTestSetup(); + 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: 16, + rating_sum: 67, + rating_average: 4.2, + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/by-id/${testParam_id}`); + + expect(testResponse.statusCode).toBe(200); + expect(testResponse.body).toMatchObject(testResult); + }) + + test('Repository process fn findByClientName(), result: "SUCCESS"', async () => { + const testParam_client_name = 'TESTCLIENT'; + const testResult: FeedbackRatingResponseDTO | null = { + rating_average: 4.2, + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/by-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: 16, + rating_sum: 67, + 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); + }) + + test('Repository process fn create(), result: "SUCCESS"', async () => { + // TicketsCreateRequestDTO interface necessary to mock auth middleware (data for client_id & user_id). + const testParam_dto: FeedbackRatingCreateDTO = { + client_id: mockId.clients.valid[1] + }; + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + + const testResult: FeedbackRating = { + client_id: testParam_dto.client_id, + count: 0, + rating_sum: 0, + 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 update(), result: "SUCCESS"', async () => { + const testParam_id: string = mockId.clients.valid[0]; + const testParam_dto: FeedbackRatingUpdateDTO = { rating: 1 }; + + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); + + const testResult: FeedbackRatingResponseDTO | null = { + rating_average: 4.0 + }; + + await dbTestSetup.addTestData(); + const testResponse = await feedbackRatingService.updateFeedbackRating(testParam_id, testParam_dto); + + expect(testResponse).toMatchObject(testResult); + }) + }) + + describe('Testing invalid fn calls', () => { + + describe('All routes, priority: express-validators, location ', () => { + + describe('Route: GET/by-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}/by-id/${testParam_id}`); + + expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); + expect(testResponse.body.headers.data).toEqual([testError]); + }) + }) + }) + + describe('All routes, priority: express-validators, location ', () => { + + describe('Route: POST/create', () => { + + test('Params: , validator: fn notEmpty() by empty string', async () => { + const testParam_dto: FeedbackRatingCreateDTO = { client_id: '' }; + const mockError = { + type: 'field', + value: '', + msg: CommonExceptionMessage.REQUIRED, + path: 'client_id', + location: 'body' + }; + + 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 isUUID() by invalid id', async () => { + const testParam_dto: FeedbackRatingCreateDTO = { client_id: 'invalid-id' }; + const testError = { + type: 'field', + value: testParam_dto.client_id, + msg: 'support-invalid-entry#client_id', + path: 'client_id', + 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([testError]); + }) + }) + }) + + describe('All routes, priority: error middleware, location: ', () => { + + describe('Route: POST/create', () => { + + test('Params: , validator: fn requirePayload() by undefined', async () =>{ + const testParam_dto = undefined; + const testError = { + type: 'field', + value: '', + msg: 'support-payload-required', + path: 'req.body', + location: 'body' + }; + + 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/mock-data/id.mock-data.json b/backend/tests/mock-data/id.mock-data.json index 3f9b765..25be9ce 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": [ + 3, + 4 + ], + "valid": [ + 1, + 2 ] }, "meta": { 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..af115d0 --- /dev/null +++ b/backend/tests/unit-tests/models/feedback-rating.model.test.ts @@ -0,0 +1,97 @@ +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 generateFeedbackRating()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Generate new object', () => { + const mockParam_dto: FeedbackRatingCreateDTO = { + client_id: mockId.clients.valid[0] + }; + jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockTimestamp); + + const testFn = feedbackRatingModel.generateFeedbackRating(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); + }) + }) + }) + + describe('Priority: fn mapFeedbackRatingUpdateDTO()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Map timestamp value to DTO, result: dto FeedbackRatingUpdateDTO', () => { + const mockParam_dto: FeedbackRatingUpdateDTO = { 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 mapAverageRating()', () =>{ + + describe('Testing valid fn calls', () => { + + test('Map average rating to response, result: dto 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; + + const testFn = feedbackRatingModel.mapAverageRating(mockParam_entity, mockParam_extended); + const expectResult: FeedbackRatingExtendedResponseDTO = { + ...mockParam_entity, + rating_average: 4.2 + }; + + expect(testFn).toEqual(expectResult); + }) + + test('Map average rating to response, result: dto 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; + + const testFn = feedbackRatingModel.mapAverageRating(mockParam_entity, 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/repositories/feedback-rating.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts new file mode 100644 index 0000000..d882ec1 --- /dev/null +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -0,0 +1,309 @@ +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 create()', () => { + + 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.create(mockParam_entity); + + 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 INSERT QUERY"; + const mockResult = null; + jest.spyOn(CommonUtils, "logError").mockReturnValue(); + const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); + + await expect(() => feedbackRatingRepository.create(mockParam_entity)) + .rejects.toThrow(expectExceptionResult); + }) + }) + }) + + describe('Database tests table , priority fn update()', () => { + + 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.update(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.update(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('Throw DBQueryErrorException by catch-block', async () => { + const mockParam_id = mockId.clients.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(() => feedbackRatingRepository.update(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); From 3c7a24c35d8062928d74b3a5e0de29122b1632ec Mon Sep 17 00:00:00 2001 From: yqni13 Date: Sun, 1 Mar 2026 00:29:28 +0100 Subject: [PATCH 3/8] feat: feedback_rating route + logic --- .../controllers/feedback-rating.controller.ts | 51 +++++++ backend/src/models/feedback-rating.model.ts | 44 ++++++ .../feedback-rating.repository.ts | 126 ++++++++++++++++++ backend/src/routes/feedback-rating.route.ts | 47 +++++++ .../src/services/feedback-rating.service.ts | 56 ++++++++ 5 files changed, 324 insertions(+) create mode 100644 backend/src/controllers/feedback-rating.controller.ts create mode 100644 backend/src/models/feedback-rating.model.ts create mode 100644 backend/src/repositories/feedback-rating.repository.ts create mode 100644 backend/src/routes/feedback-rating.route.ts create mode 100644 backend/src/services/feedback-rating.service.ts diff --git a/backend/src/controllers/feedback-rating.controller.ts b/backend/src/controllers/feedback-rating.controller.ts new file mode 100644 index 0000000..d2c04a5 --- /dev/null +++ b/backend/src/controllers/feedback-rating.controller.ts @@ -0,0 +1,51 @@ +import { NextFunction, Request, Response } from "express"; +import { checkValidation } from "../middleware/validation.middleware"; +import feedbackRatingService from "../services/feedback-rating.service"; +import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO } from "../dtos/feedback-rating.dto"; +import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; + +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); + } + } + + async postFeedbackRating(req: Request, res: Response, next: NextFunction) { + try { + checkValidation(req); + const dto: FeedbackRatingCreateDTO = req.body; + const response: FeedbackRating = await feedbackRatingService.createFeedbackRating(dto); + res.json(response); + } catch(err: any) { + next(err); + } + } +} + +export default new FeedbackRatingController(); \ No newline at end of file diff --git a/backend/src/models/feedback-rating.model.ts b/backend/src/models/feedback-rating.model.ts new file mode 100644 index 0000000..775ea1b --- /dev/null +++ b/backend/src/models/feedback-rating.model.ts @@ -0,0 +1,44 @@ +import * as CommonUtils from "../utils/common.utils"; +import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; +import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; + +class FeedbackRatingModel { + generateFeedbackRating(dto: FeedbackRatingCreateDTO): FeedbackRating { + const timestamp = CommonUtils.getTimestampUTC(); + return { + ...dto, + count: 0, + rating_sum: 0, + last_modified: timestamp, + created_on: timestamp + }; + } + + mapFeedbackRatingUpdateDTO(dto: FeedbackRatingUpdateDTO): FeedbackRatingUpdateDTO { + const timestamp = CommonUtils.getTimestampUTC(); + return { + ...dto, + last_modified: timestamp + }; + } + + mapAverageRating(entity: FeedbackRating, extended: true): FeedbackRatingExtendedResponseDTO; + mapAverageRating(entity: FeedbackRating, extended: false): FeedbackRatingResponseDTO; + + mapAverageRating(entity: FeedbackRating, extended: boolean) { + const newAverage: 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: newAverage, + last_modified: entity.last_modified, + created_on: entity.created_on + }; + } + return { rating_average: newAverage }; + } +} + +export default new FeedbackRatingModel(); \ 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..272d25f --- /dev/null +++ b/backend/src/repositories/feedback-rating.repository.ts @@ -0,0 +1,126 @@ +import { QueryResult } from "pg"; +import { DBConnection } from "../configs/db"; +import { logError } from "../utils/common.utils"; +import { DBQueryErrorException } from "../utils/exceptions/db.exception"; +import { ICreateRepository, IFindRepository } from "./interfaces/base.repository.interface"; +import { FeedbackRating } from "./interfaces/feedback-rating.entity.interface"; +import { FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; + +class FeedbackRatingRepository implements +ICreateRepository, +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 = "client_id"; + const sql = `SELECT * FROM ${this.table} ORDER BY ${orderPrio} ASC FETCH FIRST 100 ROWS ONLY;`; + 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); + } + } + + async create(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 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]; + } catch(err: any) { + const message = "DB ERROR ON INSERT QUERY"; + const method = "SUPPORT_FeedbackRatingRepository_create"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } + + async update(id: string, dto: FeedbackRatingUpdateDTO): Promise { + const filterColumn = 'client_id'; + const sql = `UPDATE ${this.table} + SET count = count + 1, rating_sum = rating_sum + $1, last_modified = $2::timestamp + WHERE ${filterColumn} = $3 + RETURNING *;`; + const values = [dto.rating, 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]; + } catch(err: any) { + const message = "DB ERROR ON UPDATE QUERY"; + const method = "SUPPORT_FeedbackRatingRepository_update"; + logError(message, method, err); + await db.close(client); + throw new DBQueryErrorException(err); + } + } +} + +export default new FeedbackRatingRepository(); \ 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..603df65 --- /dev/null +++ b/backend/src/routes/feedback-rating.route.ts @@ -0,0 +1,47 @@ +import { + getFeedbackRatingSchema as getExtendedSchema, + getExtendedFeedbackRatingSchema as getSchema, + postFeedbackRatingSchema as postSchema +} 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'; +import { requirePayload } from '../middleware/require.middleware'; + +const router = Router(); + +// findById +router.get( + '/by-id/:id', + authAdmin(), + getExtendedSchema, + factory(feedbackRatingController.getExtendedFeedbackRating) +); + +// findByClientName +router.get( + '/by-name/:client_name', + maintain(), authClient(), + getSchema, + factory(feedbackRatingController.getFeedbackRating) +); + +// findAll +router.get( + '/all', + authAdmin(), + factory(feedbackRatingController.getAllFeedbackRatings) +); + +// create +router.post( + '/create', + authAdmin(), requirePayload(), + postSchema, + factory(feedbackRatingController.postFeedbackRating) +); + +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..217c91d --- /dev/null +++ b/backend/src/services/feedback-rating.service.ts @@ -0,0 +1,56 @@ +import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; +import feedbackRatingModel from "../models/feedback-rating.model"; +import feedbackRatingRepository from "../repositories/feedback-rating.repository"; +import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; +import * as CommonUtils from "../utils/common.utils"; + +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.mapAverageRating(result, true); + return CommonUtils.mapObjTimestamps(newResult, this.timeMapTargets); + } + + async getFeedbackRatingByClientName(client_name: string): Promise { + const result: FeedbackRating | null = await feedbackRatingRepository.findByClientName(client_name); + if(!result) { + return null; + } + return feedbackRatingModel.mapAverageRating(result, false); + } + + async getAllFeedbackRatings(): Promise { + const results: FeedbackRating[] | null = await feedbackRatingRepository.findAll(); + if(!results) { + return null; + } + const newResults = results.map((result: FeedbackRating) => feedbackRatingModel.mapAverageRating(result, true)); + return CommonUtils.mapArrayTimestamps(newResults, this.timeMapTargets); + } + + async createFeedbackRating(dto: FeedbackRatingCreateDTO): Promise { + const entity: FeedbackRating = feedbackRatingModel.generateFeedbackRating(dto); + const result: FeedbackRating = await feedbackRatingRepository.create(entity); + return CommonUtils.mapObjTimestamps(result, this.timeMapTargets); + } + + async updateFeedbackRating(id: string, dto: FeedbackRatingUpdateDTO): Promise { + dto = feedbackRatingModel.mapFeedbackRatingUpdateDTO(dto); + const result: FeedbackRating | null = await feedbackRatingRepository.update(id, dto); + if(!result) { + return null; + } + return feedbackRatingModel.mapAverageRating(result, false); + } +} + +export default new FeedbackRatingService(); \ No newline at end of file From c367dbdfd22c47c54ce1d4c752024a0a1d9988e3 Mon Sep 17 00:00:00 2001 From: yqni13 Date: Fri, 6 Mar 2026 23:45:34 +0100 Subject: [PATCH 4/8] refactor: test data + email validation timing --- ...6_12-feedback-entries-create.migrations.js | 1 + .../src/middleware/auth.user.middleware.ts | 2 + backend/tests/db-container.setup.ts | 6 +-- backend/tests/db-data.setup.ts | 45 ++++++++----------- backend/tests/mock-data/id.mock-data.json | 9 ++-- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js index 5c2f862..cbd8893 100644 --- a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js +++ b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js @@ -53,6 +53,7 @@ async function up(pgm) { notNull: true, } }); + pgm.sql(`ALTER SEQUENCE feedback_entries_feedback_id_seq RESTART WITH 1;`); pgm.createTable('feedback_ratings', { client_id: { type: 'uuid', 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/tests/db-container.setup.ts b/backend/tests/db-container.setup.ts index 2adcf80..107813a 100644 --- a/backend/tests/db-container.setup.ts +++ b/backend/tests/db-container.setup.ts @@ -64,14 +64,12 @@ 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(); await this.client.query(clientData.sql, clientData.values); - const clientAdditionalData = dbTestData.getAdditionalClientsInsertSql(); - await this.client.query(clientAdditionalData.sql, clientAdditionalData.values); const userData = dbTestData.getUsersInsertSql(); await this.client.query(userData.sql, userData.values); const ticketData = dbTestData.getTicketsInsertSql(); @@ -80,8 +78,6 @@ 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(); diff --git a/backend/tests/db-data.setup.ts b/backend/tests/db-data.setup.ts index cba19e0..ad7e377 100644 --- a/backend/tests/db-data.setup.ts +++ b/backend/tests/db-data.setup.ts @@ -49,36 +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); - `; - 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']; - return { sql: sql, values: values }; - } - - getAdditionalClientsInsertSql(): 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[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']; + 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 }; } @@ -100,21 +100,12 @@ 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); - `; - 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']; - return { sql: sql, values: values }; - } - getFeedbackInsertSql(): BaseQuery { const sql = `INSERT INTO ${this.tableRecords['feedback']} - (feedback_id, client_id, user_id, rating, term_accepted, message, reviewed_on, last_modified, created_on) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); + (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.feedback.valid[0], mockId.clients.valid[0], mockId.users.valid[0], 5, true, 'test-feedback-message', '2025-01-01T14:00:08.000Z', '2025-01-01T14:00:08.000Z', '2025-01-01T14:00:08.000Z']; + 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 }; } diff --git a/backend/tests/mock-data/id.mock-data.json b/backend/tests/mock-data/id.mock-data.json index 25be9ce..8947320 100644 --- a/backend/tests/mock-data/id.mock-data.json +++ b/backend/tests/mock-data/id.mock-data.json @@ -16,12 +16,12 @@ 0 ], "new": [ - 3, + 2, 4 ], "valid": [ 1, - 2 + 3 ] }, "meta": { @@ -62,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 From 9d38fea52c664fb4e56543a080289f299fe3f97b Mon Sep 17 00:00:00 2001 From: yqni13 Date: Sat, 7 Mar 2026 23:57:27 +0100 Subject: [PATCH 5/8] test: add tests + test-app setup, update utils --- ...6_12-feedback-entries-create.migrations.js | 9 +- backend/src/dtos/feedback-rating.dto.ts | 5 +- backend/src/dtos/feedback.dto.ts | 43 ++ .../interfaces/feedback.entity.interface.ts | 11 + backend/tests/common.test-utils.ts | 21 + .../feedback-rating.integration.test.ts | 117 +-- .../repositories/feedback.integration.test.ts | 723 ++++++++++++++++++ .../repositories/users.integration.test.ts | 26 +- backend/tests/test-app.setup.ts | 28 + .../models/feedback-rating.model.test.ts | 28 +- .../unit-tests/models/feedback.model.test.ts | 167 ++++ .../feedback-rating.repositories.test.ts | 33 +- .../feedback.repositories.test.ts | 233 ++++++ .../unit-tests/utils/repository.utils.test.ts | 55 ++ 14 files changed, 1345 insertions(+), 154 deletions(-) create mode 100644 backend/src/dtos/feedback.dto.ts create mode 100644 backend/src/repositories/interfaces/feedback.entity.interface.ts create mode 100644 backend/tests/integration-tests/repositories/feedback.integration.test.ts create mode 100644 backend/tests/test-app.setup.ts create mode 100644 backend/tests/unit-tests/models/feedback.model.test.ts create mode 100644 backend/tests/unit-tests/repositories/feedback.repositories.test.ts diff --git a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js index cbd8893..600c348 100644 --- a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js +++ b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js @@ -46,13 +46,20 @@ async function up(pgm) { }, last_modified: { type: 'TIMESTAMP WITH TIME ZONE', - notNull: true + 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: { diff --git a/backend/src/dtos/feedback-rating.dto.ts b/backend/src/dtos/feedback-rating.dto.ts index e43aa6c..4408a4a 100644 --- a/backend/src/dtos/feedback-rating.dto.ts +++ b/backend/src/dtos/feedback-rating.dto.ts @@ -1,8 +1,11 @@ export interface FeedbackRatingCreateDTO { - client_id: string + client_id: string, + count?: number, + rating_sum?: number } export interface FeedbackRatingUpdateDTO { + count?: number, rating: number, last_modified?: 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/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/tests/common.test-utils.ts b/backend/tests/common.test-utils.ts index 1d3cc33..8f51996 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 injectTestClient(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 injectTestUser(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/integration-tests/repositories/feedback-rating.integration.test.ts b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts index adcde72..05429c4 100644 --- a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts @@ -65,7 +65,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); + .get(`${apiUrl}/find/id/${testParam_id}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -79,7 +79,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/by-name/${testParam_client_name}`); + .get(`${apiUrl}/find/name/${testParam_client_name}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -105,53 +105,14 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = expect(testResponse.body).toMatchObject(testResult); }) - test('Repository process fn create(), result: "SUCCESS"', async () => { - // TicketsCreateRequestDTO interface necessary to mock auth middleware (data for client_id & user_id). - const testParam_dto: FeedbackRatingCreateDTO = { - client_id: mockId.clients.valid[1] - }; - - jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); - - const testResult: FeedbackRating = { - client_id: testParam_dto.client_id, - count: 0, - rating_sum: 0, - 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 update(), result: "SUCCESS"', async () => { - const testParam_id: string = mockId.clients.valid[0]; - const testParam_dto: FeedbackRatingUpdateDTO = { rating: 1 }; - - jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); - - const testResult: FeedbackRatingResponseDTO | null = { - rating_average: 4.0 - }; - - await dbTestSetup.addTestData(); - const testResponse = await feedbackRatingService.updateFeedbackRating(testParam_id, testParam_dto); - - expect(testResponse).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/by-id/:id', () => { + describe('Route: GET/find/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { const testParam_id = 'invalid-id'; @@ -164,75 +125,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = }; const testResponse = await request(app) - .get(`${apiUrl}/by-id/${testParam_id}`); - - expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); - expect(testResponse.body.headers.data).toEqual([testError]); - }) - }) - }) - - describe('All routes, priority: express-validators, location ', () => { - - describe('Route: POST/create', () => { - - test('Params: , validator: fn notEmpty() by empty string', async () => { - const testParam_dto: FeedbackRatingCreateDTO = { client_id: '' }; - const mockError = { - type: 'field', - value: '', - msg: CommonExceptionMessage.REQUIRED, - path: 'client_id', - location: 'body' - }; - - 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 isUUID() by invalid id', async () => { - const testParam_dto: FeedbackRatingCreateDTO = { client_id: 'invalid-id' }; - const testError = { - type: 'field', - value: testParam_dto.client_id, - msg: 'support-invalid-entry#client_id', - path: 'client_id', - 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([testError]); - }) - }) - }) - - describe('All routes, priority: error middleware, location: ', () => { - - describe('Route: POST/create', () => { - - test('Params: , validator: fn requirePayload() by undefined', async () =>{ - const testParam_dto = undefined; - const testError = { - type: 'field', - value: '', - msg: 'support-payload-required', - path: 'req.body', - location: 'body' - }; - - jest.spyOn(CommonUtils, 'logError').mockImplementation(); - - const testResponse = await request(app) - .post(`${apiUrl}/create`) - .send(testParam_dto); + .get(`${apiUrl}/find/id/${testParam_id}`); expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); expect(testResponse.body.headers.data).toEqual([testError]); 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..7a94f00 --- /dev/null +++ b/backend/tests/integration-tests/repositories/feedback.integration.test.ts @@ -0,0 +1,723 @@ +import { DBQueryErrorException } from './../../../src/utils/exceptions/db.exception'; +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 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 apiUrl: string; + beforeAll(async () => { + dbTestSetup = new DBTestSetup(); + dbTestData = DBTestData.getInstance(); + 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: 5, + term_accepted: true, + message: 'test-feedback-message', + last_modified: testTimestamp, + created_on: testTimestamp + }; + + await dbTestSetup.addTestData(); + const testResponse = await request(app) + .get(`${apiUrl}/find/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 = { + // rating: 4 + 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 upsert(), result: new Feedback on existing FeedbackRating', async () => { + const app = createTestApp([ + MockUtils.injectTestClient(mockId.clients.valid[0]), + MockUtils.injectTestUser(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, + }; + // Test data FeedbackRating => position (count): [1], position (rating_sum): [2] + const dbData_FeedbackRating = dbTestData.getFeedbackRatingInsertSql().values; + 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: 4, + rating_average_new: mockResult_average_rating, + term_accepted: false, + 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 upsert(), result: new Feedback & FeedbackRating', async () => { + const app = createTestApp([ + MockUtils.injectTestClient(mockId.clients.valid[1]), + MockUtils.injectTestUser(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 upsert(), result: update existing Feedback & FeedbackRating', async () => { + const app = createTestApp([ + MockUtils.injectTestClient(mockId.clients.valid[0]), + MockUtils.injectTestUser(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 dbData_FeedbackRating = dbTestData.getFeedbackRatingInsertSql().values; + 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_update + }; + + 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 upsert(), 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, 'upsert').mockRejectedValue(new Error(mockError)); + await dbTestSetup.addTestData(); + + await expect(() => feedbackService.createFeedback(testParam_dto)) + .rejects.toThrow(new DBQueryErrorException(mockError)); // exception handling + + const testResponse_Feedback = + await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1]}); + + expect(testResponse_Feedback).toBe(null); // rollback => no insert + }) + + test('Repository process fn upsert(), 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, 'create').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: 5, + term_accepted: true, + message: 'test-feedback-message', + 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/find/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}/find/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.injectTestClient(mockId.clients.valid[0]), + MockUtils.injectTestUser(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.injectTestClient(mockId.clients.valid[0]), + MockUtils.injectTestUser(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/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/test-app.setup.ts b/backend/tests/test-app.setup.ts new file mode 100644 index 0000000..e11c7f3 --- /dev/null +++ b/backend/tests/test-app.setup.ts @@ -0,0 +1,28 @@ +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 index af115d0..24f2c03 100644 --- a/backend/tests/unit-tests/models/feedback-rating.model.test.ts +++ b/backend/tests/unit-tests/models/feedback-rating.model.test.ts @@ -8,17 +8,17 @@ const mockTimestamp = '2025-01-01T14:00:09.000Z'; describe('Unit-tests (model), priority: entity FeedbackRating', () => { - describe('Priority: fn generateFeedbackRating()', () =>{ + describe('Priority: fn generateFeedbackRatingEntity()', () =>{ describe('Testing valid fn calls', () => { - test('Generate new object', () => { + 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.generateFeedbackRating(mockParam_dto); + const testFn = feedbackRatingModel.generateFeedbackRatingEntity(mockParam_dto); const expectResult: FeedbackRating = { client_id: mockParam_dto.client_id, count: 0, @@ -29,6 +29,26 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { 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); + }) }) }) @@ -37,7 +57,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { describe('Testing valid fn calls', () => { test('Map timestamp value to DTO, result: dto FeedbackRatingUpdateDTO', () => { - const mockParam_dto: FeedbackRatingUpdateDTO = { rating: 3 }; + const mockParam_dto: FeedbackRatingUpdateDTO = { count: 1, rating: 3 }; const mockNewTimestamp = '2026-01-01T14:00:09.000Z'; jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(mockNewTimestamp); 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 index d882ec1..a497b91 100644 --- a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -215,7 +215,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { 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.create(mockParam_entity); + const testFn = await feedbackRatingRepository.create((mockClient as any), mockParam_entity); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -226,18 +226,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) }) - describe('Testing invalid fn calls', () => { - - test('Throw DBQueryErrorException by catch-block', async () => { - const mockErrorMsg = "DB ERROR ON INSERT QUERY"; - const mockResult = null; - jest.spyOn(CommonUtils, "logError").mockReturnValue(); - const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); - - await expect(() => feedbackRatingRepository.create(mockParam_entity)) - .rejects.toThrow(expectExceptionResult); - }) - }) + // Testing invalid fn call is tested in feedback.integration.test.ts file. }) describe('Database tests table , priority fn update()', () => { @@ -265,7 +254,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }; const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await feedbackRatingRepository.update(mockParam_id, mockParam_dto); + const testFn = await feedbackRatingRepository.update((mockClient as any), mockParam_id, mockParam_dto); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -281,7 +270,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { const mockResult: FeedbackRating | null = null; const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await feedbackRatingRepository.update(mockParam_id, mockParam_dto); + const testFn = await feedbackRatingRepository.update((mockClient as any), mockParam_id, mockParam_dto); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -292,18 +281,6 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) }) - describe('Testing invalid fn calls', () => { - - test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_id = mockId.clients.invalid[0]; - const mockErrorMsg = "DB ERROR ON UPDATE QUERY"; - const mockResult = null; - jest.spyOn(CommonUtils, "logError").mockReturnValue(); - const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); - - await expect(() => feedbackRatingRepository.update(mockParam_id, mockParam_dto)) - .rejects.toThrow(expectExceptionResult); - }) - }) + // Testing invalid fn call is tested in feedback.integration.test.ts file. }) }) \ 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..1f39607 --- /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 upsert()', () => { + + 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.upsert((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 file. + }) + + 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/utils/repository.utils.test.ts b/backend/tests/unit-tests/utils/repository.utils.test.ts index f2544ef..1a02b46 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 intended 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 From b79494d8cd21b34a26fe3dc8f19b73a9049ba889 Mon Sep 17 00:00:00 2001 From: yqni13 Date: Sun, 8 Mar 2026 00:14:26 +0100 Subject: [PATCH 6/8] feat: add Feedback/FeedbackRating route/logic/test --- .../controllers/feedback-rating.controller.ts | 14 +-- .../src/controllers/feedback.controller.ts | 56 +++++++++ backend/src/models/feedback-rating.model.ts | 10 +- backend/src/models/feedback.model.ts | 39 ++++++ .../feedback-rating.repository.ts | 49 ++------ .../src/repositories/feedback.repository.ts | 119 ++++++++++++++++++ backend/src/routes/feedback-rating.route.ts | 14 +-- backend/src/routes/feedback.route.ts | 51 ++++++++ .../src/services/feedback-rating.service.ts | 11 +- backend/src/services/feedback.service.ts | 69 ++++++++++ backend/src/utils/repository.utils.ts | 31 +++++ .../feedback-rating.schema.validation.ts | 13 +- .../schemata/feedback.schema.validation.ts | 99 +++++++++++++++ 13 files changed, 493 insertions(+), 82 deletions(-) create mode 100644 backend/src/controllers/feedback.controller.ts create mode 100644 backend/src/models/feedback.model.ts create mode 100644 backend/src/repositories/feedback.repository.ts create mode 100644 backend/src/routes/feedback.route.ts create mode 100644 backend/src/services/feedback.service.ts create mode 100644 backend/src/validation/schemata/feedback.schema.validation.ts diff --git a/backend/src/controllers/feedback-rating.controller.ts b/backend/src/controllers/feedback-rating.controller.ts index d2c04a5..ac199f8 100644 --- a/backend/src/controllers/feedback-rating.controller.ts +++ b/backend/src/controllers/feedback-rating.controller.ts @@ -1,8 +1,7 @@ import { NextFunction, Request, Response } from "express"; import { checkValidation } from "../middleware/validation.middleware"; import feedbackRatingService from "../services/feedback-rating.service"; -import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO } from "../dtos/feedback-rating.dto"; -import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; +import { FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO } from "../dtos/feedback-rating.dto"; class FeedbackRatingController { async getExtendedFeedbackRating(req: Request, res: Response, next: NextFunction) { @@ -35,17 +34,6 @@ class FeedbackRatingController { next(err); } } - - async postFeedbackRating(req: Request, res: Response, next: NextFunction) { - try { - checkValidation(req); - const dto: FeedbackRatingCreateDTO = req.body; - const response: FeedbackRating = await feedbackRatingService.createFeedbackRating(dto); - 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/models/feedback-rating.model.ts b/backend/src/models/feedback-rating.model.ts index 775ea1b..b0ffac4 100644 --- a/backend/src/models/feedback-rating.model.ts +++ b/backend/src/models/feedback-rating.model.ts @@ -3,12 +3,12 @@ import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRat import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; class FeedbackRatingModel { - generateFeedbackRating(dto: FeedbackRatingCreateDTO): FeedbackRating { + generateFeedbackRatingEntity(dto: FeedbackRatingCreateDTO): FeedbackRating { const timestamp = CommonUtils.getTimestampUTC(); return { ...dto, - count: 0, - rating_sum: 0, + count: dto.count ?? 0, + rating_sum: dto.rating_sum ?? 0, last_modified: timestamp, created_on: timestamp }; @@ -16,8 +16,10 @@ class FeedbackRatingModel { mapFeedbackRatingUpdateDTO(dto: FeedbackRatingUpdateDTO): FeedbackRatingUpdateDTO { const timestamp = CommonUtils.getTimestampUTC(); + // In case of an updated feedback, rating_average gets updated => no increase of count (0). return { - ...dto, + count: dto.count ?? 0, + rating: dto.rating, last_modified: timestamp }; } 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 index 272d25f..199ba24 100644 --- a/backend/src/repositories/feedback-rating.repository.ts +++ b/backend/src/repositories/feedback-rating.repository.ts @@ -1,13 +1,12 @@ -import { QueryResult } from "pg"; +import { PoolClient, QueryResult } from "pg"; import { DBConnection } from "../configs/db"; import { logError } from "../utils/common.utils"; import { DBQueryErrorException } from "../utils/exceptions/db.exception"; -import { ICreateRepository, IFindRepository } from "./interfaces/base.repository.interface"; +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 -ICreateRepository, IFindRepository { private table: string; @@ -77,49 +76,27 @@ IFindRepository { } } - async create(entity: FeedbackRating): Promise { + async create(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 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]; - } catch(err: any) { - const message = "DB ERROR ON INSERT QUERY"; - const method = "SUPPORT_FeedbackRatingRepository_create"; - logError(message, method, err); - await db.close(client); - throw new DBQueryErrorException(err); - } + const result: QueryResult = await client.query(sql, values); + return result.rows[0]; + // Used within transaction => catch & handle exceptions there. } - async update(id: string, dto: FeedbackRatingUpdateDTO): Promise { + async update(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { const filterColumn = 'client_id'; const sql = `UPDATE ${this.table} - SET count = count + 1, rating_sum = rating_sum + $1, last_modified = $2::timestamp - WHERE ${filterColumn} = $3 + SET count = count + $1, rating_sum = rating_sum + $2, last_modified = $3::timestamp + WHERE ${filterColumn} = $4 RETURNING *;`; - const values = [dto.rating, 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]; - } catch(err: any) { - const message = "DB ERROR ON UPDATE QUERY"; - const method = "SUPPORT_FeedbackRatingRepository_update"; - logError(message, method, err); - await db.close(client); - throw new DBQueryErrorException(err); - } + const values = [dto.count, dto.rating, dto.last_modified, id]; + const result: QueryResult = await client.query(sql, values); + return result.rows[0] ?? null; + // Used within transaction => catch & handle exceptions there. } } diff --git a/backend/src/repositories/feedback.repository.ts b/backend/src/repositories/feedback.repository.ts new file mode 100644 index 0000000..8cd004d --- /dev/null +++ b/backend/src/repositories/feedback.repository.ts @@ -0,0 +1,119 @@ +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 + ${this.table}.*, + clients.name AS client_name, + users.email AS user_email + FROM ${this.table} + LEFT JOIN clients ON ${this.table}.client_id = clients.client_id + LEFT JOIN users ON ${this.table}.user_id = users.user_id + 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 if insert has conflict on existing entry for same client_id and user_id (unique combination). + * @returns {FeedbackResponseDTO | null} entity: Feedback expanded by rating_old value for FeedbackRating process. + */ + async upsert(client: PoolClient, entity: Partial): Promise { + const sql = ` + WITH existing 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 existing) 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; + // Used within transaction => catch & handle exceptions there. + } +} + +export default new FeedbackRepository(); \ No newline at end of file diff --git a/backend/src/routes/feedback-rating.route.ts b/backend/src/routes/feedback-rating.route.ts index 603df65..268381b 100644 --- a/backend/src/routes/feedback-rating.route.ts +++ b/backend/src/routes/feedback-rating.route.ts @@ -1,7 +1,6 @@ import { getFeedbackRatingSchema as getExtendedSchema, getExtendedFeedbackRatingSchema as getSchema, - postFeedbackRatingSchema as postSchema } from '../validation/schemata/feedback-rating.schema.validation'; import { Router } from 'express'; import { authAdmin } from '../middleware/auth.admin.middleware'; @@ -9,13 +8,12 @@ import { awaitHandlerFactory as factory } from '../middleware/awaitHandlerFactor import feedbackRatingController from '../controllers/feedback-rating.controller'; import { authClient } from '../middleware/auth.client.middleware'; import { maintain } from '../middleware/maintenance.middleware'; -import { requirePayload } from '../middleware/require.middleware'; const router = Router(); // findById router.get( - '/by-id/:id', + '/find/id/:id', authAdmin(), getExtendedSchema, factory(feedbackRatingController.getExtendedFeedbackRating) @@ -23,7 +21,7 @@ router.get( // findByClientName router.get( - '/by-name/:client_name', + '/find/name/:client_name', maintain(), authClient(), getSchema, factory(feedbackRatingController.getFeedbackRating) @@ -36,12 +34,4 @@ router.get( factory(feedbackRatingController.getAllFeedbackRatings) ); -// create -router.post( - '/create', - authAdmin(), requirePayload(), - postSchema, - factory(feedbackRatingController.postFeedbackRating) -); - 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..ebe6843 --- /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( + '/find/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 index 217c91d..33f7a3f 100644 --- a/backend/src/services/feedback-rating.service.ts +++ b/backend/src/services/feedback-rating.service.ts @@ -1,3 +1,4 @@ +import { PoolClient } from 'pg'; import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; import feedbackRatingModel from "../models/feedback-rating.model"; import feedbackRatingRepository from "../repositories/feedback-rating.repository"; @@ -37,15 +38,15 @@ class FeedbackRatingService { return CommonUtils.mapArrayTimestamps(newResults, this.timeMapTargets); } - async createFeedbackRating(dto: FeedbackRatingCreateDTO): Promise { - const entity: FeedbackRating = feedbackRatingModel.generateFeedbackRating(dto); - const result: FeedbackRating = await feedbackRatingRepository.create(entity); + async createFeedbackRating(client: PoolClient, dto: FeedbackRatingCreateDTO): Promise { + const entity: FeedbackRating = feedbackRatingModel.generateFeedbackRatingEntity(dto); + const result: FeedbackRating = await feedbackRatingRepository.create(client, entity); return CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async updateFeedbackRating(id: string, dto: FeedbackRatingUpdateDTO): Promise { + async updateFeedbackRating(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { dto = feedbackRatingModel.mapFeedbackRatingUpdateDTO(dto); - const result: FeedbackRating | null = await feedbackRatingRepository.update(id, dto); + const result: FeedbackRating | null = await feedbackRatingRepository.update(client, id, dto); if(!result) { return null; } diff --git a/backend/src/services/feedback.service.ts b/backend/src/services/feedback.service.ts new file mode 100644 index 0000000..d02a016 --- /dev/null +++ b/backend/src/services/feedback.service.ts @@ -0,0 +1,69 @@ +import { + FeedbackCreateDTO, + FeedbackFilterDTO, + FeedbackResponseDTO, + FeedbackUpdateReviewDTO +} from "../dtos/feedback.dto"; +import { FeedbackRatingCreateDTO, 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-RATINGS TRANSACTION"; + const method = "SUPPORT_FeedbackService_createFeedbackTransaction"; + + return RepoUtils.asTransaction(message, method, async(client) => { + const entity: Partial = feedbackModel.generateFeedbackEntity(dto); + const result: FeedbackResponseDTO | null = await feedbackRepository.upsert(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 = feedbackRatingModel.mapFeedbackRatingUpdateDTO({ 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 = feedbackRatingModel.mapFeedbackRatingUpdateDTO({ rating: rating_delta }); + } + const update = await feedbackRatingService.updateFeedbackRating(client, result.client_id, dtoUpdateFR); + if(!update) { + const dtoCreateFR: FeedbackRatingCreateDTO = { + client_id: dto.client_id, + count: 1, + rating_sum: dto.rating + }; + await feedbackRatingService.createFeedbackRating(client, dtoCreateFR); + } + // Use rating from dto if no other ratings for this client exist. + return feedbackModel.toFeedbackResponseDTO(result, 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..1bebfd2 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]. @@ -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 index 27523cc..7c073e0 100644 --- a/backend/src/validation/schemata/feedback-rating.schema.validation.ts +++ b/backend/src/validation/schemata/feedback-rating.schema.validation.ts @@ -1,6 +1,5 @@ -import { body, param, ValidationChain } from 'express-validator'; +import { param, ValidationChain } from 'express-validator'; import * as CommonValidators from "../common.validation"; -import { CommonExceptionMessage as Message } from '../../utils/enums/common-exception-messages.enum'; export const getFeedbackRatingSchema: ValidationChain[] = [ param('id') @@ -13,14 +12,4 @@ export const getFeedbackRatingSchema: ValidationChain[] = [ export const getExtendedFeedbackRatingSchema: ValidationChain[] = [ param('client_name') .custom((content: string) => CommonValidators.validateRequestRouteParam(content)) -]; - -export const postFeedbackRatingSchema: ValidationChain[] = [ - body('client_id') - .trim() - .notEmpty() - .withMessage(Message.REQUIRED) - .bail() - .isUUID(4) - .withMessage('support-invalid-entry#client_id') ]; \ 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 From 95ba431be22c30b7c8c888c242f116f0cc028ceb Mon Sep 17 00:00:00 2001 From: yqni13 Date: Sun, 8 Mar 2026 00:34:09 +0100 Subject: [PATCH 7/8] docs: update documentation --- README.md | 22 +++++++++++++++------- backend/package-lock.json | 2 +- backend/package.json | 4 ++-- docs/CHANGELOG.md | 15 +++++++++++++-- 4 files changed, 31 insertions(+), 12 deletions(-) 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/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/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 From 5761c5d347b3f32f744f3665ca5b0e7df9522e3b Mon Sep 17 00:00:00 2001 From: yqni13 Date: Mon, 9 Mar 2026 01:45:26 +0100 Subject: [PATCH 8/8] refactor: code review changes Refactor code regarding Code Review. >> SUPPORT-64-add-feedback-route #82 --- ...6_12-feedback-entries-create.migrations.js | 6 +- backend/src/configs/db.ts | 2 + backend/src/models/feedback-rating.model.ts | 37 +++++++--- .../src/repositories/clients.repository.ts | 20 ------ .../feedback-rating.repository.ts | 16 +++-- .../src/repositories/feedback.repository.ts | 20 ++---- backend/src/routes/feedback-rating.route.ts | 4 +- backend/src/routes/feedback.route.ts | 2 +- backend/src/services/clients.service.ts | 5 -- .../src/services/feedback-rating.service.ts | 37 ++++++---- backend/src/services/feedback.service.ts | 23 +++--- backend/src/utils/repository.utils.ts | 2 +- backend/tests/common.test-utils.ts | 4 +- .../repositories/clients.integration.test.ts | 19 ----- .../feedback-rating.integration.test.ts | 31 ++++---- .../repositories/feedback.integration.test.ts | 70 +++++++++---------- backend/tests/test-app.setup.ts | 1 - .../models/feedback-rating.model.test.ts | 59 ++++++++++++++-- .../repositories/clients.repositories.test.ts | 53 -------------- .../feedback-rating.repositories.test.ts | 14 ++-- .../feedback.repositories.test.ts | 6 +- .../unit-tests/utils/repository.utils.test.ts | 2 +- 22 files changed, 207 insertions(+), 226 deletions(-) diff --git a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js index 600c348..193902c 100644 --- a/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js +++ b/backend/migrations/1772192517446_12-feedback-entries-create.migrations.js @@ -29,7 +29,7 @@ async function up(pgm) { rating: { type: 'smallint', notNull: true, - default: 0 + default: 5 }, term_accepted: { type: 'boolean', @@ -81,11 +81,13 @@ async function up(pgm) { }, last_modified: { type: 'TIMESTAMP WITH TIME ZONE', - notNull: true + notNull: true, + default: 'NOW()' }, created_on: { type: 'TIMESTAMP WITH TIME ZONE', notNull: true, + default: 'NOW()' } }); }; 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/models/feedback-rating.model.ts b/backend/src/models/feedback-rating.model.ts index b0ffac4..ba74839 100644 --- a/backend/src/models/feedback-rating.model.ts +++ b/backend/src/models/feedback-rating.model.ts @@ -1,5 +1,10 @@ +import { + FeedbackRatingCreateDTO, + FeedbackRatingExtendedResponseDTO, + FeedbackRatingResponseDTO, + FeedbackRatingUpdateDTO +} from "../dtos/feedback-rating.dto"; import * as CommonUtils from "../utils/common.utils"; -import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; class FeedbackRatingModel { @@ -16,7 +21,8 @@ class FeedbackRatingModel { mapFeedbackRatingUpdateDTO(dto: FeedbackRatingUpdateDTO): FeedbackRatingUpdateDTO { const timestamp = CommonUtils.getTimestampUTC(); - // In case of an updated feedback, rating_average gets updated => no increase of count (0). + // 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, @@ -24,22 +30,33 @@ class FeedbackRatingModel { }; } - mapAverageRating(entity: FeedbackRating, extended: true): FeedbackRatingExtendedResponseDTO; - mapAverageRating(entity: FeedbackRating, extended: false): FeedbackRatingResponseDTO; + toFeedbackRatingResponseDTO(entity: FeedbackRating, extended: true): FeedbackRatingExtendedResponseDTO; + toFeedbackRatingResponseDTO(entity: FeedbackRating, extended: false): FeedbackRatingResponseDTO; - mapAverageRating(entity: FeedbackRating, extended: boolean) { - const newAverage: number = +((entity.rating_sum / entity.count).toFixed(1)); + 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: newAverage, - last_modified: entity.last_modified, - created_on: entity.created_on + rating_average: delta, + last_modified: CommonUtils.getTimestampUTC(new Date(entity.last_modified)), + created_on: CommonUtils.getTimestampUTC(new Date(entity.created_on)) }; } - return { rating_average: newAverage }; + 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)); + } } } diff --git a/backend/src/repositories/clients.repository.ts b/backend/src/repositories/clients.repository.ts index cdf0ffe..26adc3e 100644 --- a/backend/src/repositories/clients.repository.ts +++ b/backend/src/repositories/clients.repository.ts @@ -33,26 +33,6 @@ class ClientsRepository implements ICreateRepository, IUpdateFlagReposi } } - async findByName(name: string): Promise { - const filterColumn = 'name'; - const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; - const value = [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_ClientsRepository_findByName"; - logError(message, method, err); - await db.close(client); - throw new DBQueryErrorException(err); - } - } - async findByActiveKey(hash: string): Promise { const sql = `SELECT * FROM ${this.table} WHERE api_key_hash = $1 AND status = $2;`; const values = [hash, ApiKeyStatus.ACTIVE]; diff --git a/backend/src/repositories/feedback-rating.repository.ts b/backend/src/repositories/feedback-rating.repository.ts index 199ba24..84e9a82 100644 --- a/backend/src/repositories/feedback-rating.repository.ts +++ b/backend/src/repositories/feedback-rating.repository.ts @@ -58,8 +58,8 @@ IFindRepository { } async findAll(): Promise { - const orderPrio = "client_id"; - const sql = `SELECT * FROM ${this.table} ORDER BY ${orderPrio} ASC FETCH FIRST 100 ROWS ONLY;`; + const orderPrio = "created_on"; + const sql = `SELECT * FROM ${this.table} ORDER BY ${orderPrio} DESC;`; const db = DBConnection.getInstance(); let client: any; try { @@ -76,7 +76,10 @@ IFindRepository { } } - async create(client: PoolClient, entity: FeedbackRating): Promise { + /** + * @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) @@ -84,10 +87,12 @@ IFindRepository { 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]; - // Used within transaction => catch & handle exceptions there. } - async update(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { + /** + * @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 @@ -96,7 +101,6 @@ IFindRepository { const values = [dto.count, dto.rating, dto.last_modified, id]; const result: QueryResult = await client.query(sql, values); return result.rows[0] ?? null; - // Used within transaction => catch & handle exceptions there. } } diff --git a/backend/src/repositories/feedback.repository.ts b/backend/src/repositories/feedback.repository.ts index 8cd004d..4b23fc6 100644 --- a/backend/src/repositories/feedback.repository.ts +++ b/backend/src/repositories/feedback.repository.ts @@ -15,14 +15,7 @@ class FeedbackRepository { async findById(id: number): Promise { const filterColumn = "feedback_id"; - const sql = `SELECT - ${this.table}.*, - clients.name AS client_name, - users.email AS user_email - FROM ${this.table} - LEFT JOIN clients ON ${this.table}.client_id = clients.client_id - LEFT JOIN users ON ${this.table}.user_id = users.user_id - WHERE ${filterColumn} = $1;`; + const sql = `SELECT * FROM ${this.table} WHERE ${filterColumn} = $1;`; const value = [id]; const db = DBConnection.getInstance(); let client: any; @@ -84,12 +77,12 @@ class FeedbackRepository { /** * - * @description Update if insert has conflict on existing entry for same client_id and user_id (unique combination). - * @returns {FeedbackResponseDTO | null} entity: Feedback expanded by rating_old value for FeedbackRating process. + * @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 upsert(client: PoolClient, entity: Partial): Promise { + async upsertInTa(client: PoolClient, entity: Partial): Promise { const sql = ` - WITH existing AS ( + WITH pre_update_data AS ( SELECT rating FROM ${this.table} WHERE client_id = $1 AND user_id = $2 @@ -107,12 +100,11 @@ class FeedbackRepository { WHERE NOT (${this.table}.message IS NOT NULL AND ${this.table}.reviewed_on IS NOT NULL) RETURNING ${this.table}.*, - (SELECT rating FROM existing) AS rating_old; + (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; - // Used within transaction => catch & handle exceptions there. } } diff --git a/backend/src/routes/feedback-rating.route.ts b/backend/src/routes/feedback-rating.route.ts index 268381b..57f6282 100644 --- a/backend/src/routes/feedback-rating.route.ts +++ b/backend/src/routes/feedback-rating.route.ts @@ -13,7 +13,7 @@ const router = Router(); // findById router.get( - '/find/id/:id', + '/id/:id', authAdmin(), getExtendedSchema, factory(feedbackRatingController.getExtendedFeedbackRating) @@ -21,7 +21,7 @@ router.get( // findByClientName router.get( - '/find/name/:client_name', + '/name/:client_name', maintain(), authClient(), getSchema, factory(feedbackRatingController.getFeedbackRating) diff --git a/backend/src/routes/feedback.route.ts b/backend/src/routes/feedback.route.ts index ebe6843..3e64833 100644 --- a/backend/src/routes/feedback.route.ts +++ b/backend/src/routes/feedback.route.ts @@ -18,7 +18,7 @@ const router = Router(); // findById router.get( - '/find/id/:id', + '/id/:id', authAdmin(), getSchema, factory(feedbackController.getFeedback) diff --git a/backend/src/services/clients.service.ts b/backend/src/services/clients.service.ts index c2c1f4f..4876f8c 100644 --- a/backend/src/services/clients.service.ts +++ b/backend/src/services/clients.service.ts @@ -29,11 +29,6 @@ class ClientsService { return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); } - async getClientByName(name: string): Promise { - const result = await clientsRepository.findByName(name); - return !result ? null : CommonUtils.mapObjTimestamps(result, this.timeMapTargets); - } - /** * @description Usage for apikey authentication in auth.middleware.ts. */ diff --git a/backend/src/services/feedback-rating.service.ts b/backend/src/services/feedback-rating.service.ts index 33f7a3f..262a87d 100644 --- a/backend/src/services/feedback-rating.service.ts +++ b/backend/src/services/feedback-rating.service.ts @@ -1,9 +1,13 @@ +import { + FeedbackRatingCreateDTO, + FeedbackRatingExtendedResponseDTO, + FeedbackRatingResponseDTO, + FeedbackRatingUpdateDTO +} from "../dtos/feedback-rating.dto"; import { PoolClient } from 'pg'; -import { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.dto"; import feedbackRatingModel from "../models/feedback-rating.model"; import feedbackRatingRepository from "../repositories/feedback-rating.repository"; import { FeedbackRating } from "../repositories/interfaces/feedback-rating.entity.interface"; -import * as CommonUtils from "../utils/common.utils"; class FeedbackRatingService { private timeMapTargets: string[]; @@ -17,8 +21,8 @@ class FeedbackRatingService { if(!result) { return null; } - const newResult = feedbackRatingModel.mapAverageRating(result, true); - return CommonUtils.mapObjTimestamps(newResult, this.timeMapTargets); + const newResult = feedbackRatingModel.toFeedbackRatingResponseDTO(result, true); + return feedbackRatingModel.toFeedbackRatingResponseDTO(newResult, true); } async getFeedbackRatingByClientName(client_name: string): Promise { @@ -26,7 +30,7 @@ class FeedbackRatingService { if(!result) { return null; } - return feedbackRatingModel.mapAverageRating(result, false); + return feedbackRatingModel.toFeedbackRatingResponseDTO(result, false); } async getAllFeedbackRatings(): Promise { @@ -34,23 +38,30 @@ class FeedbackRatingService { if(!results) { return null; } - const newResults = results.map((result: FeedbackRating) => feedbackRatingModel.mapAverageRating(result, true)); - return CommonUtils.mapArrayTimestamps(newResults, this.timeMapTargets); + const newResults = + results.map((result: FeedbackRating) => feedbackRatingModel.toFeedbackRatingResponseDTO(result, true)); + return feedbackRatingModel.toFeedbackRatingResponseDTOArray(newResults, true); } - async createFeedbackRating(client: PoolClient, dto: FeedbackRatingCreateDTO): Promise { + /** + * @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.create(client, entity); - return CommonUtils.mapObjTimestamps(result, this.timeMapTargets); + const result: FeedbackRating = await feedbackRatingRepository.createInTa(client, entity); + return feedbackRatingModel.toFeedbackRatingResponseDTO(result, true); } - async updateFeedbackRating(client: PoolClient, id: string, dto: FeedbackRatingUpdateDTO): Promise { + /** + * @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.update(client, id, dto); + const result: FeedbackRating | null = await feedbackRatingRepository.updateInTa(client, id, dto); if(!result) { return null; } - return feedbackRatingModel.mapAverageRating(result, false); + return feedbackRatingModel.toFeedbackRatingResponseDTO(result, false); } } diff --git a/backend/src/services/feedback.service.ts b/backend/src/services/feedback.service.ts index d02a016..aca3a74 100644 --- a/backend/src/services/feedback.service.ts +++ b/backend/src/services/feedback.service.ts @@ -4,7 +4,7 @@ import { FeedbackResponseDTO, FeedbackUpdateReviewDTO } from "../dtos/feedback.dto"; -import { FeedbackRatingCreateDTO, FeedbackRatingUpdateDTO } from "../dtos/feedback-rating.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"; @@ -28,34 +28,39 @@ class FeedbackService { * @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-RATINGS TRANSACTION"; - const method = "SUPPORT_FeedbackService_createFeedbackTransaction"; + 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.upsert(client, entity); + 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 = feedbackRatingModel.mapFeedbackRatingUpdateDTO({ count: 1, rating: dto.rating }); + 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 = feedbackRatingModel.mapFeedbackRatingUpdateDTO({ rating: rating_delta }); + dtoUpdateFR = { rating: rating_delta }; } - const update = await feedbackRatingService.updateFeedbackRating(client, result.client_id, dtoUpdateFR); + 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.createFeedbackRating(client, dtoCreateFR); + await feedbackRatingService.createFeedbackRatingInTa(client, dtoCreateFR); } // Use rating from dto if no other ratings for this client exist. - return feedbackModel.toFeedbackResponseDTO(result, update ?? { rating_average: dto.rating }); + return { + ...result, + rating_average_new: update?.rating_average ?? dto.rating + } }) } diff --git a/backend/src/utils/repository.utils.ts b/backend/src/utils/repository.utils.ts index 1bebfd2..0ca5af9 100644 --- a/backend/src/utils/repository.utils.ts +++ b/backend/src/utils/repository.utils.ts @@ -26,7 +26,7 @@ export function mapFilteredQueryValues>(dto: T, ta const argGroups: string[] = []; const timestampObj = {}; Object.entries(dto).forEach(([key, content]) => { - if(key !== '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) { diff --git a/backend/tests/common.test-utils.ts b/backend/tests/common.test-utils.ts index 8f51996..d50c945 100644 --- a/backend/tests/common.test-utils.ts +++ b/backend/tests/common.test-utils.ts @@ -40,7 +40,7 @@ export function disableConsoleMessages() { /** * @description Used in combination with createTestApp() to mock client authentication for flexible testing. */ -export function injectTestClient(clientId: string) { +export function injectTestClientId(clientId: string) { return function (req: Request, res: Response, next: NextFunction) { (req as any).apiClients = { client_id: clientId }; next(); @@ -50,7 +50,7 @@ export function injectTestClient(clientId: string) { /** * @description Used in combination with createTestApp() to mock user authentication for flexible testing. */ -export function injectTestUser(userId: string) { +export function injectTestUserId(userId: string) { return function (req: Request, res: Response, next: NextFunction) { (req as any).apiUsers = { user_id: userId }; next(); diff --git a/backend/tests/integration-tests/repositories/clients.integration.test.ts b/backend/tests/integration-tests/repositories/clients.integration.test.ts index ff303a2..a868924 100644 --- a/backend/tests/integration-tests/repositories/clients.integration.test.ts +++ b/backend/tests/integration-tests/repositories/clients.integration.test.ts @@ -76,25 +76,6 @@ describe('Integration-tests (repository), priority: entity Clients', () => { expect(testResponse).toMatchObject(testResult); }) - - test('Repository process fn findByName(), result: "SUCCESS"', async () => { - const mockParam_name = 'TESTCLIENT'; - const testResult: ClientsExistResponseDTO | null = { - client_id: mockId.clients.valid[0], - name: 'TESTCLIENT', - api_key_hash: secrets.TEST_APIKEY_HASH, - status: ApiKeyStatus.ACTIVE, - flag: null, - last_use: testTimestamp, - last_modified: testTimestamp, - created_on: testTimestamp - }; - - await dbTestSetup.addTestData(); - const testResponse = await clientsService.getClientByName(mockParam_name); - - expect(testResponse).toMatchObject(testResult); - }) test('Repository process fn findStatusByName(), result: "SUCCESS"', async () => { const testParam_name = 'TESTCLIENT'; diff --git a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts index 05429c4..56692be 100644 --- a/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback-rating.integration.test.ts @@ -1,16 +1,11 @@ import { NextFunction, Request, Response } from "express"; -import * as CommonUtils from '../../../src/utils/common.utils'; 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 { FeedbackRatingCreateDTO, FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO, FeedbackRatingUpdateDTO } from "../../../src/dtos/feedback-rating.dto"; -import { FeedbackRating } from "../../../src/repositories/interfaces/feedback-rating.entity.interface"; -import feedbackRatingService from "../../../src/services/feedback-rating.service"; +import { FeedbackRatingExtendedResponseDTO, FeedbackRatingResponseDTO } from "../../../src/dtos/feedback-rating.dto"; import { ErrorStatusCodes } from "../../../src/utils/errorStatusCodes.utils"; -import { CommonExceptionMessage } from "../../../src/utils/enums/common-exception-messages.enum"; - jest.mock('../../../src/middleware/auth.admin.middleware', () => ({ authAdmin: jest.fn(() => (req: Request, res: Response, next: NextFunction) => next()) @@ -26,6 +21,7 @@ jest.mock('../../../src/middleware/maintenance.middleware', () => ({ })); import app from '../../../src/app'; +import { DBTestData } from "../../db-data.setup"; jest.setTimeout(60000); @@ -34,9 +30,13 @@ 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'); @@ -56,8 +56,8 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = const testParam_id = mockId.clients.valid[0]; const testResult: FeedbackRatingExtendedResponseDTO | null = { client_id: testParam_id, - count: 16, - rating_sum: 67, + count: dbData_FeedbackRating[1], + rating_sum: dbData_FeedbackRating[2], rating_average: 4.2, last_modified: testTimestamp, created_on: testTimestamp @@ -65,21 +65,22 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = await dbTestSetup.addTestData(); const testResponse = await request(app) - .get(`${apiUrl}/find/id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); }) test('Repository process fn findByClientName(), result: "SUCCESS"', async () => { - const testParam_client_name = 'TESTCLIENT'; + 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}/find/name/${testParam_client_name}`); + .get(`${apiUrl}/name/${testParam_client_name}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -89,8 +90,8 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = const testResult: FeedbackRatingExtendedResponseDTO[] | null = [ { client_id: mockId.clients.valid[0], - count: 16, - rating_sum: 67, + count: dbData_FeedbackRating[1], + rating_sum: dbData_FeedbackRating[2], rating_average: 4.2, last_modified: testTimestamp, created_on: testTimestamp @@ -112,7 +113,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = describe('All routes, priority: express-validators, location ', () => { - describe('Route: GET/find/id/:id', () => { + describe('Route: GET/id/:id', () => { test('Params: , validator: fn isUUID() by invalid id', async () => { const testParam_id = 'invalid-id'; @@ -125,7 +126,7 @@ describe('Integration-tests (repository), priority: entity FeedbackRating', () = }; const testResponse = await request(app) - .get(`${apiUrl}/find/id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); expect(testResponse.body.headers.data).toEqual([testError]); diff --git a/backend/tests/integration-tests/repositories/feedback.integration.test.ts b/backend/tests/integration-tests/repositories/feedback.integration.test.ts index 7a94f00..dee45d9 100644 --- a/backend/tests/integration-tests/repositories/feedback.integration.test.ts +++ b/backend/tests/integration-tests/repositories/feedback.integration.test.ts @@ -1,4 +1,3 @@ -import { DBQueryErrorException } from './../../../src/utils/exceptions/db.exception'; import * as CommonUtils from '../../../src/utils/common.utils'; import * as MockUtils from "../../common.test-utils"; import { createTestApp } from '../../test-app.setup'; @@ -15,6 +14,7 @@ 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]; @@ -43,10 +43,14 @@ 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'); @@ -71,16 +75,16 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { feedback_id: testParam_id, client_id: testValidClientsId, user_id: testValidUsersId, - rating: 5, - term_accepted: true, - message: 'test-feedback-message', + 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}/find/id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(200); expect(testResponse.body).toMatchObject(testResult); @@ -89,7 +93,6 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { test('Repository process fn findByFilter(), result: "SUCCESS"', async () => { const app = createTestApp([], router, apiUrl); const testParam_dto: FeedbackFilterDTO = { - // rating: 4 term_accepted: false }; const testResult: FeedbackResponseDTO[] | null = null; @@ -103,10 +106,10 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { expect(testResponse.body).toBe(testResult); }) - test('Repository process fn upsert(), result: new Feedback on existing FeedbackRating', async () => { + test('Repository process fn upsertInTa(), result: new Feedback on existing FeedbackRating', async () => { const app = createTestApp([ - MockUtils.injectTestClient(mockId.clients.valid[0]), - MockUtils.injectTestUser(mockId.users.valid[1]) + 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 = { @@ -114,8 +117,6 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { rating: 4, term_accepted: false, }; - // Test data FeedbackRating => position (count): [1], position (rating_sum): [2] - const dbData_FeedbackRating = dbTestData.getFeedbackRatingInsertSql().values; const mockResult_average_rating = Number(((dbData_FeedbackRating[2] + testParam_dto.rating) / (dbData_FeedbackRating[1] + 1)).toFixed(1)); @@ -125,9 +126,9 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { feedback_id: mockId.feedback.new[0], client_id: testValidClientsId, user_id: mockId.users.valid[1], - rating: 4, + rating: testParam_dto.rating, rating_average_new: mockResult_average_rating, - term_accepted: false, + term_accepted: testParam_dto.term_accepted, last_modified: testTimestamp, created_on: testTimestamp }; @@ -141,10 +142,10 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { expect(testResponse.body).toMatchObject(testResult) }) - test('Repository process fn upsert(), result: new Feedback & FeedbackRating', async () => { + test('Repository process fn upsertInTa(), result: new Feedback & FeedbackRating', async () => { const app = createTestApp([ - MockUtils.injectTestClient(mockId.clients.valid[1]), - MockUtils.injectTestUser(mockId.users.valid[0]) + MockUtils.injectTestClientId(mockId.clients.valid[1]), + MockUtils.injectTestUserId(mockId.users.valid[0]) ], router, apiUrl); const email = dbTestData.getUsersInsertSql().values[7]; const testParam_dto: FeedbackRequestCreateDTO = { @@ -181,10 +182,10 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { expect(testResponse_FeedbackRating?.rating_sum).toBe(testParam_dto.rating); }) - test('Repository process fn upsert(), result: update existing Feedback & FeedbackRating', async () => { + test('Repository process fn upsertInTa(), result: update existing Feedback & FeedbackRating', async () => { const app = createTestApp([ - MockUtils.injectTestClient(mockId.clients.valid[0]), - MockUtils.injectTestUser(mockId.users.valid[0]) + MockUtils.injectTestClientId(mockId.clients.valid[0]), + MockUtils.injectTestUserId(mockId.users.valid[0]) ], router, apiUrl); const email = dbTestData.getUsersInsertSql().values[1]; const testParam_dto: FeedbackRequestCreateDTO = { @@ -194,7 +195,6 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { message: 'test-feedback-message-updated-client[0]' }; // Test data FeedbackRating => position (count): [1], position (rating_sum): [2] - const dbData_FeedbackRating = dbTestData.getFeedbackRatingInsertSql().values; 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)); @@ -211,7 +211,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { term_accepted: testParam_dto.term_accepted, message: testParam_dto.message, last_modified: testTimestamp_update, - created_on: testTimestamp_update + created_on: testTimestamp }; await dbTestSetup.addTestData(); @@ -228,7 +228,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { expect(testResponse_FeedbackRating?.rating_sum).toBe(mockResult_rating_sum); }) - test('Repository process fn upsert(), result: Exception on Feedback + Rollback', async () => { + 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], @@ -240,11 +240,11 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { jest.spyOn(CommonUtils, 'logError').mockImplementation(); jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); - jest.spyOn(feedbackRepository, 'upsert').mockRejectedValue(new Error(mockError)); + jest.spyOn(feedbackRepository, 'upsertInTa').mockRejectedValue(new Error(mockError)); await dbTestSetup.addTestData(); await expect(() => feedbackService.createFeedback(testParam_dto)) - .rejects.toThrow(new DBQueryErrorException(mockError)); // exception handling + .rejects.toThrow(new DBQueryErrorException(mockError)); const testResponse_Feedback = await feedbackService.searchFeedbackEntriesByFilter({client_id: mockId.clients.valid[1]}); @@ -252,7 +252,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { expect(testResponse_Feedback).toBe(null); // rollback => no insert }) - test('Repository process fn upsert(), result: Exception on FeedbackRating + Rollback', async () => { + 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], @@ -263,7 +263,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { const mockError = 'FeedbackRating-create-mock-error'; jest.spyOn(CommonUtils, "getTimestampUTC").mockReturnValue(testTimestamp); - jest.spyOn(feedbackRatingRepository, 'create').mockRejectedValue(new Error(mockError)); + jest.spyOn(feedbackRatingRepository, 'createInTa').mockRejectedValue(new Error(mockError)); await dbTestSetup.addTestData(); const testResponse_Feedback = @@ -293,9 +293,9 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { feedback_id: testParam_id, client_id: testValidClientsId, user_id: testValidUsersId, - rating: 5, - term_accepted: true, - message: 'test-feedback-message', + rating: dbData_Feedback[2], + term_accepted: dbData_Feedback[3], + message: dbData_Feedback[4], reviewed_on: mockTimestamp, last_modified: mockTimestamp, created_on: testTimestamp @@ -327,7 +327,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { }; }) - describe('Route: GET/find/id/:id', () => { + describe('Route: GET/id/:id', () => { test('Params: , validator: fn isInt() by value as string', async () => { const testParam_id = 'invalid-id'; @@ -335,7 +335,7 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { testError['value'] = testParam_id; const testResponse = await request(app) - .get(`${apiUrl}/find/id/${testParam_id}`); + .get(`${apiUrl}/id/${testParam_id}`); expect(testResponse.statusCode).toBe(ErrorStatusCodes.InvalidPropertiesException); expect(testResponse.body.headers.data).toEqual([testError]); @@ -542,8 +542,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { let testData: Partial; beforeEach(() => { app = createTestApp([ - MockUtils.injectTestClient(mockId.clients.valid[0]), - MockUtils.injectTestUser(mockId.users.valid[0]) + MockUtils.injectTestClientId(mockId.clients.valid[0]), + MockUtils.injectTestUserId(mockId.users.valid[0]) ], router, apiUrl); testData = { user_email: 'max.muster@test.com', @@ -700,8 +700,8 @@ describe('Integration-tests (repository), priority: entity Feedback', () => { let app: any; beforeEach(() => { app = createTestApp([ - MockUtils.injectTestClient(mockId.clients.valid[0]), - MockUtils.injectTestUser(mockId.users.valid[0]) + MockUtils.injectTestClientId(mockId.clients.valid[0]), + MockUtils.injectTestUserId(mockId.users.valid[0]) ], router, apiUrl); }) diff --git a/backend/tests/test-app.setup.ts b/backend/tests/test-app.setup.ts index e11c7f3..39bd836 100644 --- a/backend/tests/test-app.setup.ts +++ b/backend/tests/test-app.setup.ts @@ -4,7 +4,6 @@ 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) diff --git a/backend/tests/unit-tests/models/feedback-rating.model.test.ts b/backend/tests/unit-tests/models/feedback-rating.model.test.ts index 24f2c03..7d205a6 100644 --- a/backend/tests/unit-tests/models/feedback-rating.model.test.ts +++ b/backend/tests/unit-tests/models/feedback-rating.model.test.ts @@ -72,11 +72,11 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { }) }) - describe('Priority: fn mapAverageRating()', () =>{ + describe('Priority: fn toFeedbackRatingResponseDTO()', () =>{ describe('Testing valid fn calls', () => { - test('Map average rating to response, result: dto FeedbackRatingExtendedResponseDTO', () => { + test('Convert entity to dto + map average rating, result: FeedbackRatingExtendedResponseDTO', () => { const mockParam_entity: FeedbackRating = { client_id: mockId.clients.valid[0], count: 16, @@ -85,8 +85,8 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { created_on: mockTimestamp }; const mockParam_extended: boolean = true; - - const testFn = feedbackRatingModel.mapAverageRating(mockParam_entity, mockParam_extended); + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const testFn = feedbackRatingModel.toFeedbackRatingResponseDTO(mockParam_entity, mockParam_extended); const expectResult: FeedbackRatingExtendedResponseDTO = { ...mockParam_entity, rating_average: 4.2 @@ -95,7 +95,7 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { expect(testFn).toEqual(expectResult); }) - test('Map average rating to response, result: dto FeedbackRatingResponseDTO', () => { + test('Convert entity to dto + map average rating, result: FeedbackRatingResponseDTO', () => { const mockParam_entity: FeedbackRating = { client_id: mockId.clients.valid[0], count: 16, @@ -104,8 +104,8 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { created_on: mockTimestamp }; const mockParam_extended: boolean = false; - - const testFn = feedbackRatingModel.mapAverageRating(mockParam_entity, mockParam_extended); + jest.spyOn(CommonUtils, 'getTimestampUTC').mockReturnValue(mockTimestamp); + const testFn = feedbackRatingModel.toFeedbackRatingResponseDTO(mockParam_entity, mockParam_extended); const expectResult: FeedbackRatingResponseDTO = { rating_average: 4.2 }; @@ -114,4 +114,49 @@ describe('Unit-tests (model), priority: entity FeedbackRating', () => { }) }) }) + + 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/repositories/clients.repositories.test.ts b/backend/tests/unit-tests/repositories/clients.repositories.test.ts index 79fe842..41d4925 100644 --- a/backend/tests/unit-tests/repositories/clients.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/clients.repositories.test.ts @@ -96,59 +96,6 @@ describe('Unit-tests (repository), priority: entity Clients', () => { }) }) - describe('Database tests table , priority: fn findByName()', () => { - - describe('Testing valid fn calls', () => { - - let sql: string; - beforeEach(() => { - sql = `SELECT`; - }); - - test('Return data for existing entry, params: valid ', async () => { - const mockParam_name = 'valid_clients_test_name'; - const mockResult: Clients | null = structuredClone(mockData); - const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await clientsRepository.findByName(mockParam_name); - - expect(testFn).toEqual(mockResult); - expect(DBConnection.getInstance).toHaveBeenCalled(); - expect(mockClient.query).toHaveBeenCalledWith( - expect.stringContaining(sql), - expect.arrayContaining([mockParam_name]) - ); - }) - - test('Return null for non-existing entry, params: invalid ', async () => { - const mockParam_name = 'invalid_clients_test_name'; - const mockResult: Clients | null = null; - const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await clientsRepository.findByName(mockParam_name); - - expect(testFn).toEqual(mockResult); - expect(DBConnection.getInstance).toHaveBeenCalled(); - expect(mockClient.query).toHaveBeenCalledWith( - expect.stringContaining(sql), - expect.arrayContaining([mockParam_name]) - ); - }) - }) - - describe('Testing invalid fn calls', () => { - - test('Throw DBQueryErrorException by catch-block', async () => { - const mockParam_name = 'invalid_clients_test_name'; - const mockErrorMsg = "DB ERROR ON SELECT QUERY"; - const mockResult: Clients | null = null; - jest.spyOn(CommonUtils, "logError").mockReturnValue(); - const _ = MockUtils.mapMockDbClient(mockResult, mockBoolean, mockErrorMsg); - - await expect(() => clientsRepository.findByName(mockParam_name)) - .rejects.toThrow(expectExceptionResult); - }) - }) - }) - describe('Database tests table , priority: fn findByActiveKey()', () => { describe('Testing valid fn calls', () => { diff --git a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts index a497b91..8e1fea1 100644 --- a/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback-rating.repositories.test.ts @@ -194,7 +194,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) }) - describe('Database tests table , priority fn create()', () => { + describe('Database tests table , priority fn createInTa()', () => { let sql: string; let mockParam_entity: FeedbackRating; @@ -215,7 +215,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { 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.create((mockClient as any), mockParam_entity); + const testFn = await feedbackRatingRepository.createInTa((mockClient as any), mockParam_entity); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -226,10 +226,10 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) }) - // Testing invalid fn call is tested in feedback.integration.test.ts file. + // Testing invalid fn call is tested in feedback.integration.test.ts due to transaction structure. }) - describe('Database tests table , priority fn update()', () => { + describe('Database tests table , priority fn updateInTa()', () => { let sql: string; let mockParam_dto: FeedbackRatingUpdateDTO; @@ -254,7 +254,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }; const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await feedbackRatingRepository.update((mockClient as any), mockParam_id, mockParam_dto); + const testFn = await feedbackRatingRepository.updateInTa((mockClient as any), mockParam_id, mockParam_dto); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -270,7 +270,7 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { const mockResult: FeedbackRating | null = null; const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await feedbackRatingRepository.update((mockClient as any), mockParam_id, mockParam_dto); + const testFn = await feedbackRatingRepository.updateInTa((mockClient as any), mockParam_id, mockParam_dto); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -281,6 +281,6 @@ describe('Unit-tests (repository), priority: entity FeedbackRating', () => { }) }) - // Testing invalid fn call is tested in feedback.integration.test.ts file. + // 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 index 1f39607..db760fd 100644 --- a/backend/tests/unit-tests/repositories/feedback.repositories.test.ts +++ b/backend/tests/unit-tests/repositories/feedback.repositories.test.ts @@ -143,7 +143,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { }) }) - describe('Database tests table , priority fn upsert()', () => { + describe('Database tests table , priority fn upsertInTa()', () => { describe('Testing valid fn calls', () => { @@ -155,7 +155,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { const mockResult: Feedback = structuredClone(mockData); const mockClient = MockUtils.mapMockDbClient(mockResult); - const testFn = await feedbackRepository.upsert((mockClient as any), mockParam_entity); + const testFn = await feedbackRepository.upsertInTa((mockClient as any), mockParam_entity); expect(testFn).toEqual(mockResult); expect(DBConnection.getInstance).toHaveBeenCalled(); @@ -166,7 +166,7 @@ describe('Unit-tests (repository), priority: entity Feedback', () => { }) }) - // Testing invalid fn call is tested in feedback.integration.test.ts file. + // Testing invalid fn call is tested in feedback.integration.test.ts due to transaction structure. }) describe('Database tests table , priority fn updateReview()', () => { diff --git a/backend/tests/unit-tests/utils/repository.utils.test.ts b/backend/tests/unit-tests/utils/repository.utils.test.ts index 1a02b46..53c873e 100644 --- a/backend/tests/unit-tests/utils/repository.utils.test.ts +++ b/backend/tests/unit-tests/utils/repository.utils.test.ts @@ -135,7 +135,7 @@ describe('Unit-tests (utils), priority: synonym RepoUtils', () => { describe('Testing invalid fn calls', () => { - test('Fn asTransaction(), params: for intended Rollback', async () => { + 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();