diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2deef83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +Dockerfile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 224592b..3170cd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ env: GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} + STRIPE_SECRET_KEY: ${{secrets.STRIPE_SECRET_KEYT}} + jobs: build-lint-test-coverage: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5190e01 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json . + +RUN npm install + +COPY . . + +EXPOSE $PORT + +CMD ["npm", "run", "dev"] diff --git a/README.md b/README.md index 0d6c5f0..453c9d5 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,24 @@ logger.debug('This is a debug message'); npm test ``` +### Setting up docker and using it + +- Download and install docker + ``` + https://www.docker.com/products/docker-desktop/ + ``` +- Download Subsystem for Linux for none linux users +- Set environment varibles like database host to postgresdb + +- Building the image, you must navigate to the project directory in the terminal, then run + ``` + docker-compose up --build + ``` +- Stoping docker-compose container, run + ``` + docker-compose down + ``` + ## Authors - [Maxime Mizero](https://github.com/maxCastro1) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99ed647 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + postgresdb: + image: postgres + environment: + POSTGRES_USER: $DEV_DB_USER + POSTGRES_PASSWORD: $DEV_DB_PASS + POSTGRES_DB: $DEV_DB_NAME + volumes: + - knights-data:/var/lib/postgresql/data + + node-app: + build: . + volumes: + - .:/app + - /app/node_modules + image: knights-app:1.0 + env_file: + - ./.env + ports: + - $PORT:$PORT + depends_on: + - postgresdb + +volumes: + knights-data: diff --git a/package.json b/package.json index ef7acc5..53cc5a7 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "reflect-metadata": "^0.2.2", "socket.io": "^4.7.5", "source-map-support": "^0.5.21", + "stripe": "^15.8.0", "superagent": "^9.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", diff --git a/src/__test__/auth.test.ts b/src/__test__/auth.test.ts new file mode 100644 index 0000000..229fab4 --- /dev/null +++ b/src/__test__/auth.test.ts @@ -0,0 +1,154 @@ +import request from 'supertest'; +import express, { Request, Response } from 'express'; +import { + userVerificationService, + userRegistrationService, + userLoginService, + userEnableTwoFactorAuth, + userDisableTwoFactorAuth, + userValidateOTP, + userResendOtpService, + logoutService, +} from '../services'; +import { userPasswordResetService } from '../services/userServices/userPasswordResetService'; +import { sendPasswordResetLinkService } from '../services/userServices/sendResetPasswordLinkService'; +import { activateUserService } from '../services/updateUserStatus/activateUserService'; +import { deactivateUserService } from '../services/updateUserStatus/deactivateUserService'; +import { userProfileUpdateServices } from '../services/userServices/userProfileUpdateServices'; +import { activateUser, disable2FA, disactivateUser, enable2FA, login, logout, resendOTP, sampleAPI, sendPasswordResetLink, userPasswordReset, userProfileUpdate, userRegistration, userVerification, verifyOTP } from '../controllers'; + +// Mock the services +jest.mock('../services', () => ({ + userVerificationService: jest.fn(), + userRegistrationService: jest.fn(), + userLoginService: jest.fn(), + userEnableTwoFactorAuth: jest.fn(), + userDisableTwoFactorAuth: jest.fn(), + userValidateOTP: jest.fn(), + userResendOtpService: jest.fn(), + logoutService: jest.fn(), +})); + +jest.mock('../services/userServices/userPasswordResetService', () => ({ + userPasswordResetService: jest.fn(), +})); + +jest.mock('../services/userServices/sendResetPasswordLinkService', () => ({ + sendPasswordResetLinkService: jest.fn(), +})); + +jest.mock('../services/updateUserStatus/activateUserService', () => ({ + activateUserService: jest.fn(), +})); + +jest.mock('../services/updateUserStatus/deactivateUserService', () => ({ + deactivateUserService: jest.fn(), +})); + +jest.mock('../services/userServices/userProfileUpdateServices', () => ({ + userProfileUpdateServices: jest.fn(), +})); + +const app = express(); +app.use(express.json()); + +app.post('/register', userRegistration); +app.post('/verify', userVerification); +app.post('/login', login); +app.post('/enable-2fa', enable2FA); +app.post('/disable-2fa', disable2FA); +app.post('/verify-otp', verifyOTP); +app.post('/resend-otp', resendOTP); +app.get('/sample', sampleAPI); +app.post('/reset-password', userPasswordReset); +app.post('/send-reset-link', sendPasswordResetLink); +app.post('/activate', activateUser); +app.post('/deactivate', disactivateUser); +app.post('/logout', logout); +app.put('/update-profile', userProfileUpdate); + +describe('User Controller', () => { + it('should call userRegistrationService on /register', async () => { + (userRegistrationService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(201).send()); + await request(app).post('/register').send({}); + expect(userRegistrationService).toHaveBeenCalled(); + }); + + it('should call userVerificationService on /verify', async () => { + (userVerificationService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/verify').send({}); + expect(userVerificationService).toHaveBeenCalled(); + }); + + it('should call userLoginService on /login', async () => { + (userLoginService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/login').send({}); + expect(userLoginService).toHaveBeenCalled(); + }); + + it('should call userEnableTwoFactorAuth on /enable-2fa', async () => { + (userEnableTwoFactorAuth as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/enable-2fa').send({}); + expect(userEnableTwoFactorAuth).toHaveBeenCalled(); + }); + + it('should call userDisableTwoFactorAuth on /disable-2fa', async () => { + (userDisableTwoFactorAuth as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/disable-2fa').send({}); + expect(userDisableTwoFactorAuth).toHaveBeenCalled(); + }); + + it('should call userValidateOTP on /verify-otp', async () => { + (userValidateOTP as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/verify-otp').send({}); + expect(userValidateOTP).toHaveBeenCalled(); + }); + + it('should call userResendOtpService on /resend-otp', async () => { + (userResendOtpService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/resend-otp').send({}); + expect(userResendOtpService).toHaveBeenCalled(); + }); + + it('should return 200 on /sample', async () => { + const response = await request(app).get('/sample'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ message: 'Token is valid' }); + }); + + it('should call userPasswordResetService on /reset-password', async () => { + (userPasswordResetService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/reset-password').send({}); + expect(userPasswordResetService).toHaveBeenCalled(); + }); + + it('should call sendPasswordResetLinkService on /send-reset-link', async () => { + (sendPasswordResetLinkService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/send-reset-link').send({}); + expect(sendPasswordResetLinkService).toHaveBeenCalled(); + }); + + it('should call activateUserService on /activate', async () => { + (activateUserService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/activate').send({}); + expect(activateUserService).toHaveBeenCalled(); + }); + + it('should call deactivateUserService on /deactivate', async () => { + (deactivateUserService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/deactivate').send({}); + expect(deactivateUserService).toHaveBeenCalled(); + }); + + it('should call logoutService on /logout', async () => { + (logoutService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/logout').send({}); + expect(logoutService).toHaveBeenCalled(); + }); + + it('should call userProfileUpdateServices on /update-profile', async () => { + (userProfileUpdateServices as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).put('/update-profile').send({}); + expect(userProfileUpdateServices).toHaveBeenCalled(); + }); +}); diff --git a/src/__test__/cart.test.ts b/src/__test__/cart.test.ts index 4d6d1f0..7fe7146 100644 --- a/src/__test__/cart.test.ts +++ b/src/__test__/cart.test.ts @@ -14,6 +14,7 @@ import { cleanDatabase } from './test-assets/DatabaseCleanup'; const vendor1Id = uuid(); const buyer1Id = uuid(); const buyer2Id = uuid(); +const buyer3Id = uuid(); const product1Id = uuid(); const product2Id = uuid(); const catId = uuid(); @@ -52,7 +53,7 @@ const sampleBuyer1: UserInterface = { id: buyer1Id, firstName: 'buyer1', lastName: 'user', - email: 'elijahladdiedv@gmail.com', + email: 'manger@gmail.com', password: 'password', userType: 'Buyer', gender: 'Male', @@ -65,7 +66,7 @@ const sampleBuyer2: UserInterface = { id: buyer2Id, firstName: 'buyer1', lastName: 'user', - email: 'buyer1112@example.com', + email: 'elijahladdiedv@example.com', password: 'password', userType: 'Buyer', gender: 'Male', @@ -73,6 +74,18 @@ const sampleBuyer2: UserInterface = { photoUrl: 'https://example.com/photo.jpg', role: 'BUYER', }; +const sampleBuyer3: UserInterface = { + id: buyer3Id, + firstName: 'buyer1', + lastName: 'user', + email: 'elhladdiedv@example.com', + password: 'password', + userType: 'Admin', + gender: 'Male', + phoneNumber: '121163800', + photoUrl: 'https://example.com/photo.jpg', + role: 'ADMIN', +}; const sampleCat = { id: catId, @@ -175,7 +188,7 @@ afterAll(async () => { server.close(); }); -describe('Cart management for guest/buyer', () => { +describe('Cart| Order management for guest/buyer', () => { describe('Creating new product', () => { it('should create new product', async () => { const response = await request(app) @@ -376,6 +389,108 @@ describe('Cart management for guest/buyer', () => { }); }); + describe('Order management tests', () => { + let orderId: any; + let productId: any; + let feedbackId: any; + let feedback2Id: any; + describe('Create order', () => { + it('should return 400 when user ID is not provided', async () => { + const response = await request(app) + .post('/product/orders') + .send({ + address: { + country: 'Test Country', + city: 'Test City', + street: 'Test Street', + }, + }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + }); + + it('should return orders for the buyer', async () => { + const response = await request(app) + .get('/product/client/orders') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + orderId = response.body.data.orders[0]?.id; + productId = response.body.data.orders[0]?.orderItems[0]?.product?.id; + }); + it('should return 404 if the buyer has no orders', async () => { + const response = await request(app) + .get('/product/client/orders') + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + expect(response.status).toBe(404); + expect(response.body.message).toBeUndefined; + }); + + it('should return transaction history for the buyer', async () => { + const response = await request(app) + .get('/product/orders/history') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Transaction history retrieved successfully'); + }); + + it('should return 400 when user ID is not provided', async () => { + const response = await request(app) + .get('/product/orders/history') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + }); + + describe('Update order', () => { + it('should update order status successfully', async () => { + const response = await request(app) + .put(`/product/client/orders/${orderId}`) + .send({ orderStatus: 'completed' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + }); + describe('Add feedback to the product with order', () => { + it('should create new feedback to the ordered product', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId, comment: 'Well this product looks so fantastic' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + feedbackId = response.body.data.id + }); + it('should create new feedback to the ordered product', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId, comment: 'Murigalike this product looks so fantastic' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + feedback2Id = response.body.data.id + }); + it('should updated existing feedback successfully', async () => { + const response = await request(app) + .put(`/feedback/update/${feedbackId}`,) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + it('should remove recorded feedback', async () => { + const response = await request(app) + .delete(`/feedback/delete/${feedbackId}`) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + it('should remove recorder feedback as admin ', async () => { + const response = await request(app) + .delete(`/feedback/admin/delete/${feedback2Id}`) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer3Id, sampleBuyer3.email)}`); + expect(response.status).toBe(401); + }); + }); + }); + describe('Deleting product from cart', () => { it('should return 404 if product does not exist in cart', async () => { const response = await request(app) @@ -511,101 +626,3 @@ describe('Cart management for guest/buyer', () => { }); }); }); - -describe('Order management tests', () => { - let orderId: string | null; - describe('Create order', () => { - it('should return 400 when user ID is not provided', async () => { - const response = await request(app) - .post('/product/orders') - .send({ - address: { - country: 'Test Country', - city: 'Test City', - street: 'Test Street', - }, - }) - .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); - expect(response.status).toBe(400); - }); - - it('should create a new order', async () => { - const response = await request(app) - .post('/product/orders') - .send({ - address: { - country: 'Test Country', - city: 'Test City', - street: 'Test Street', - }, - }) - .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); - - expect(response.status).toBe(400); - expect(response.body.message).toBeUndefined; - orderId = response.body.data?.orderId; // Assuming orderId is returned in response - }); - - it('should insert a new order', async () => { - const response = await request(app) - .post('/product/orders') - .send({ - address: { - country: 'Test Country', - city: 'Test City', - street: 'Test Street', - }, - }) - .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); - - expect(response.status).toBe(400); - expect(response.body.message).toBeUndefined; - orderId = response.body.data?.orderId; // Assuming orderId is returned in response - }); - }); - - describe('Get orders', () => { - it('should return orders for the buyer', async () => { - const response = await request(app) - .get('/product/client/orders') - .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); - expect(response.status).toBe(404); - expect(response.body.message).toBeUndefined; - }); - - it('should return 404 if the buyer has no orders', async () => { - const response = await request(app) - .get('/product/client/orders') - .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); - expect(response.status).toBe(404); - expect(response.body.message).toBeUndefined; - }); - }); - - describe('Get transaction history', () => { - it('should return transaction history for the buyer', async () => { - const response = await request(app) - .get('/product/orders/history') - .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); - expect(response.status).toBe(404); - expect(response.body.message).toBe('No transaction history found'); - }); - - it('should return 400 when user ID is not provided', async () => { - const response = await request(app) - .get('/product/orders/history') - .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); - expect(response.status).toBe(404); - }); - }); - - describe('Update order', () => { - it('should update order status successfully', async () => { - const response = await request(app) - .put(`/product/client/orders/${orderId}`) - .send({ orderStatus: 'delivered' }) - .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); - expect(response.status).toBe(500); - }); - }); -}); diff --git a/src/__test__/getProduct.test.ts b/src/__test__/getProduct.test.ts index ecd2281..96201dd 100644 --- a/src/__test__/getProduct.test.ts +++ b/src/__test__/getProduct.test.ts @@ -7,9 +7,11 @@ import { User, UserInterface } from '../entities/User'; import { v4 as uuid } from 'uuid'; import { Product } from '../entities/Product'; import { Category } from '../entities/Category'; +import { Cart } from '../entities/Cart'; import { cleanDatabase } from './test-assets/DatabaseCleanup'; const vendor1Id = uuid(); +const BuyerID = uuid(); const product1Id = uuid(); const Invalidproduct = '11278df2-d026-457a-9471-4749f038df68'; const catId = uuid(); @@ -37,6 +39,18 @@ const sampleVendor1: UserInterface = { photoUrl: 'https://example.com/photo.jpg', role: 'VENDOR', }; +const sampleBuyer1: UserInterface = { + id: BuyerID, + firstName: 'vendor1o', + lastName: 'user', + email: 'buyer10@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '000380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; const sampleCat = { id: catId, @@ -53,7 +67,7 @@ const sampleProduct1 = { vendor: sampleVendor1, categories: [sampleCat], }; - +let cardID : string; beforeAll(async () => { const connection = await dbConnection(); @@ -61,7 +75,8 @@ beforeAll(async () => { await categoryRepository?.save({ ...sampleCat }); const userRepository = connection?.getRepository(User); - await userRepository?.save({ ...sampleVendor1 }); + await userRepository?.save({ ...sampleVendor1}); + await userRepository?.save({ ...sampleBuyer1 }); const productRepository = connection?.getRepository(Product); await productRepository?.save({ ...sampleProduct1 }); @@ -69,7 +84,6 @@ beforeAll(async () => { afterAll(async () => { await cleanDatabase(); - server.close(); }); @@ -122,3 +136,23 @@ describe('Get single product', () => { expect(response.body.message).toBe('Product not found'); }, 10000); }); +describe('Cart Order and payment functionalities', () => { + it('should create a cart for a product', async () => { + const productId = product1Id; + const quantity = 8; + + const token = getAccessToken(BuyerID, sampleBuyer1.email); + + const response = await request(app) + .post('/cart') + .set('Authorization', `Bearer ${token}`) + .send({ productId, quantity }); + + + expect(response.status).toBe(201); + expect(response.body.data.cart).toBeDefined(); + cardID = JSON.stringify(response.body.data.cart.id) + }); + +} +) \ No newline at end of file diff --git a/src/__test__/index.test.ts b/src/__test__/index.test.ts new file mode 100644 index 0000000..dfa39c4 --- /dev/null +++ b/src/__test__/index.test.ts @@ -0,0 +1,107 @@ +// index.test.ts + +import request from 'supertest'; +import { app, server } from '../index'; +import { dbConnection } from '../startups/dbConnection'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + await dbConnection(); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('Express App', () => { + it('should have JSON parsing enabled', async () => { + const response = await request(app) + .get('/test') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + }); + + it('Should respond to posting route', async () => { + const response = await request(app) + .post('/test/posting') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + }); + + it('should respond to a valid route', async () => { + const response = await request(app) + .get('/test') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Route works!'); + }); + + it('should not respond to invalid route', async () => { + const response = await request(app) + .get('/testing/mon') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(404); + }); + + it('should respond to an invalid route with an appropriate message', async () => { + const response = await request(app) + .get('/mon') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(404); + }); +}); + +describe('Application JSON', () =>{ +it('Should respond to json', async () =>{ +const data ={ + name: 'John', + age: 20, + gender:'male' +}; +const response = await request(app) +.post('/test/posting') +.set('Content-Type', 'application/json') +.send(data); + +expect(response.statusCode).toBe(200); +}); +}); + +describe('APIs protection', () => { + it('should respond with a 401 status for unauthorized request', async () => { + const response = await request(app) + .get('/test/secure') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(401); + }); + + it('should respond with a 500 status for server errors', async () => { + const response = await request(app) + .get('/test/error') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(500); + }); + + it('should respond with correct data', async () => { + const data = { + name: 'John', + age: 20, + gender: 'male' + }; + const response = await request(app) + .post('/test/posting') + .set('Content-Type', 'application/json') + .send(data); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined; + }); +}); \ No newline at end of file diff --git a/src/__test__/index.utils.test.ts b/src/__test__/index.utils.test.ts new file mode 100644 index 0000000..aa8ba39 --- /dev/null +++ b/src/__test__/index.utils.test.ts @@ -0,0 +1,34 @@ +import { formatMoney, formatDate } from '../utils/index'; + +describe('Utility Functions', () => { + describe('formatMoney', () => { + it('should format a number as currency with default currency RWF', () => { + expect(formatMoney(1234.56)).toBeDefined(); + }); + + it('should format a number as currency with specified currency', () => { + expect(formatMoney(1234.56, 'USD')).toBe('$1,234.56'); + expect(formatMoney(1234.56, 'EUR')).toBe('€1,234.56'); + }); + + it('should format a number with no cents if amount is a whole number', () => { + expect(formatMoney(1234)).toBeDefined(); + }); + }); + + describe('formatDate', () => { + it('should format a date string into a more readable format', () => { + const date = new Date('2024-05-28'); + expect(formatDate(date)).toBe('May 28, 2024'); + }); + + it('should format another date correctly', () => { + const date = new Date('2020-01-01'); + expect(formatDate(date)).toBe('January 1, 2020'); + }); + + it('should handle invalid date strings gracefully', () => { + expect(formatDate(new Date('invalid-date'))).toBe('Invalid Date'); + }); + }); +}); diff --git a/src/__test__/logger.test.ts b/src/__test__/logger.test.ts new file mode 100644 index 0000000..71a766d --- /dev/null +++ b/src/__test__/logger.test.ts @@ -0,0 +1,80 @@ +import { dbConnection } from '../startups/dbConnection'; +import logger from '../utils/logger'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { server } from '../../src/index'; + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + http: jest.fn(), + }, +})); + +beforeAll(async () => { + const connection = await dbConnection(); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('Logger', () => { + it('should create a logger with the correct configuration', () => { + expect(logger).toBeDefined(); + }); + + it('should log messages with the correct level and format', () => { + const testMessage = 'Test log message'; + const testLevel = 'info'; + + logger.log(testLevel, testMessage); + + expect(logger.log).toHaveBeenCalledWith(testLevel, testMessage); + }); + + it('should correctly handle info level logs', () => { + const testMessage = 'Test info message'; + + logger.info(testMessage); + + expect(logger.info).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle warn level logs', () => { + const testMessage = 'Test warn message'; + + logger.warn(testMessage); + + expect(logger.warn).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle error level logs', () => { + const testMessage = 'Test error message'; + + logger.error(testMessage); + + expect(logger.error).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle debug level logs', () => { + const testMessage = 'Test debug message'; + + logger.debug(testMessage); + + expect(logger.debug).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle http level logs', () => { + const testMessage = 'Test http message'; + + logger.http(testMessage); + + expect(logger.http).toHaveBeenCalledWith(testMessage); + }); +}); \ No newline at end of file diff --git a/src/__test__/product.entities.test.ts b/src/__test__/product.entities.test.ts new file mode 100644 index 0000000..82819e3 --- /dev/null +++ b/src/__test__/product.entities.test.ts @@ -0,0 +1,174 @@ +import { validate } from 'class-validator'; +import { Repository } from 'typeorm'; +import { Product } from '../entities/Product'; +import { User } from '../entities/User'; +import { Category } from '../entities/Category'; +import { Coupon } from '../entities/coupon'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { dbConnection } from '../startups/dbConnection'; +import { server } from '../index'; + +// Sample data +const catId = uuid(); +const vendor3Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const couponId1 = uuid(); +const couponId2 = uuid(); +const couponCode1 = 'DISCOUNT10'; +const couponCode2 = 'DISCOUNT20'; + +if (!process.env.TEST_USER_EMAIL || !process.env.TEST_USER_PASS) throw new Error('TEST_USER_PASS or TEST_USER_EMAIL not set in .env'); + +const sampleVendor3 = new User(); +sampleVendor3.id = vendor3Id; +sampleVendor3.firstName = 'Vendor3'; +sampleVendor3.lastName = 'User'; +sampleVendor3.email = process.env.TEST_USER_EMAIL; +sampleVendor3.password = process.env.TEST_USER_PASS; +sampleVendor3.userType = 'Vendor'; +sampleVendor3.gender = 'Male'; +sampleVendor3.phoneNumber = '32638099634'; +sampleVendor3.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor3.role = 'VENDOR'; + +const sampleProduct1 = new Product(); +sampleProduct1.id = product1Id; +sampleProduct1.name = 'Test Product 1'; +sampleProduct1.description = 'Amazing product 1'; +sampleProduct1.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct1.newPrice = 200; +sampleProduct1.quantity = 10; +sampleProduct1.vendor = sampleVendor3; + +const sampleProduct2 = new Product(); +sampleProduct2.id = product2Id; +sampleProduct2.name = 'Test Product 2'; +sampleProduct2.description = 'Amazing product 2'; +sampleProduct2.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct2.newPrice = 250; +sampleProduct2.quantity = 15; +sampleProduct2.vendor = sampleVendor3; + +const sampleCoupon1 = new Coupon(); +sampleCoupon1.id = couponId1; +sampleCoupon1.code = couponCode1; +sampleCoupon1.discountRate = 20; +sampleCoupon1.expirationDate = new Date('2025-01-01'); +sampleCoupon1.maxUsageLimit = 100; +sampleCoupon1.discountType = 'percentage'; +sampleCoupon1.product = sampleProduct1; +sampleCoupon1.vendor = sampleVendor3; + +const sampleCoupon2 = new Coupon(); +sampleCoupon2.id = couponId2; +sampleCoupon2.code = couponCode2; +sampleCoupon2.discountRate = 15; +sampleCoupon2.expirationDate = new Date('2025-01-01'); +sampleCoupon2.maxUsageLimit = 50; +sampleCoupon2.discountType = 'percentage'; +sampleCoupon2.product = sampleProduct2; +sampleCoupon2.vendor = sampleVendor3; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +let productRepository: Repository; +let userRepository: Repository; +let categoryRepository: Repository; +let couponRepository: Repository; + +beforeAll(async () => { + const connection = await dbConnection(); + if (!connection) { + console.error('Failed to connect to the database'); + return; + } + + userRepository = connection.getRepository(User); + categoryRepository = connection.getRepository(Category); + couponRepository = connection.getRepository(Coupon); + productRepository = connection.getRepository(Product); + + await userRepository.save(sampleVendor3); + await categoryRepository.save(sampleCat); + + const category1 = categoryRepository.create({ name: 'Category 1' }); + const category2 = categoryRepository.create({ name: 'Category 2' }); + await categoryRepository.save([category1, category2]); + + sampleProduct1.categories = [category1]; + sampleProduct2.categories = [category2]; + await productRepository.save([sampleProduct1, sampleProduct2]); + await couponRepository.save([sampleCoupon1, sampleCoupon2]); +}); + +afterAll(async () => { + await cleanDatabase(); + const connection = await dbConnection(); + if (connection) { + await connection.close(); + } + server.close(); +}); + +describe('Product Entity', () => { + it('should create all entities related to product entity', async () => { + const product = await productRepository.save(sampleProduct2); + expect(product).toBeDefined(); + }); + + it('should not validate a product with missing required fields', async () => { + const product = new Product(); + const errors = await validate(product); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should enforce array constraints on images', async () => { + const product = productRepository.create({ + id: uuid(), + vendor: sampleVendor3, + name: 'Sample Product', + description: 'This is a sample product', + images: [], + newPrice: 100.0, + quantity: 10, + isAvailable: true, + }); + + const errors = await validate(product); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints?.arrayNotEmpty).toBeDefined(); + }); + + it('should handle relationships correctly', async () => { + const category1 = await categoryRepository.findOne({ where: { name: 'Category 1' } }); + const category2 = await categoryRepository.findOne({ where: { name: 'Category 2' } }); + + const product = productRepository.create({ + id: uuid(), + vendor: sampleVendor3, + name: 'Sample Product', + description: 'This is a sample product', + images: ['image1.jpg', 'image2.jpg'], + newPrice: 100.0, + quantity: 10, + isAvailable: true, + categories: [category1 as Category, category2 as Category], + }); + + await productRepository.save(product); + + const savedProduct = await productRepository.findOne({ + where: { id: product.id }, + relations: ['vendor', 'categories', 'coupons'], + }); + + expect(savedProduct).toBeDefined(); + expect(savedProduct?.vendor).toBeDefined(); + expect(savedProduct?.categories).toBeDefined(); + }); +}); diff --git a/src/__test__/test-assets/DatabaseCleanup.ts b/src/__test__/test-assets/DatabaseCleanup.ts index 3674dfb..b8739d1 100644 --- a/src/__test__/test-assets/DatabaseCleanup.ts +++ b/src/__test__/test-assets/DatabaseCleanup.ts @@ -12,11 +12,13 @@ import { User } from '../../entities/User'; import { server } from '../..'; import { VendorOrderItem } from '../../entities/VendorOrderItem'; import { VendorOrders } from '../../entities/vendorOrders'; +import { Feedback } from '../../entities/Feedback'; export const cleanDatabase = async () => { const connection = getConnection(); // Delete from child tables first + await connection.getRepository(Feedback).delete({}); await connection.getRepository(Transaction).delete({}); await connection.getRepository(Coupon).delete({}); await connection.getRepository(VendorOrderItem).delete({}); diff --git a/src/__test__/user.entity.test.ts b/src/__test__/user.entity.test.ts new file mode 100644 index 0000000..aa7738d --- /dev/null +++ b/src/__test__/user.entity.test.ts @@ -0,0 +1,274 @@ +import { validate } from 'class-validator'; +import { getConnection, Repository } from 'typeorm'; +import { User, UserInterface } from '../entities/User'; +import { Order } from '../entities/Order'; +import { Transaction } from '../entities/transaction'; +import { Product } from '../entities/Product'; +import { OrderItem } from '../entities/OrderItem'; +import { VendorOrderItem } from '../entities/VendorOrderItem'; +import { VendorOrders } from '../entities/vendorOrders'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { server } from '../index'; +import { v4 as uuid } from 'uuid'; +import { dbConnection } from '../startups/dbConnection'; + +const adminId = uuid(); +const vendorId = uuid(); +const vendor2Id = uuid(); +const buyerId = uuid(); +const productId = uuid(); +const orderId = uuid(); +const order2Id = uuid(); +const orderItemId = uuid(); +const vendorOrderId = uuid(); +const vendorOrderItemId = uuid(); +const vendorOrder2Id = uuid(); +const catId = uuid(); + +if (!process.env.TEST_USER_EMAIL || !process.env.TEST_BUYER_EMAIL || !process.env.TEST_VENDOR1_EMAIL || !process.env.TEST_VENDOR_EMAIL || !process.env.TEST_USER_PASS) throw new Error('TEST_USER_PASS or TEST_USER_EMAIL not set in .env'); + +const sampleAdmin: UserInterface = { + id: adminId, + firstName: 'admin', + lastName: 'user', + email:process.env.TEST_USER_EMAIL, + password: process.env.TEST_USER_PASS, + userType: 'Admin', + gender: 'Male', + phoneNumber: '126380997', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'ADMIN', +}; +const sampleVendor: UserInterface = { + id: vendorId, + firstName: 'vendor', + lastName: 'user', + email:process.env.TEST_VENDOR_EMAIL, + password: process.env.TEST_USER_PASS, + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'VENDOR', +}; +const sampleVendor2: UserInterface = { + id: vendor2Id, + firstName: 'vendor', + lastName: 'user', + email: process.env.TEST_VENDOR1_EMAIL, + password: process.env.TEST_USER_PASS, + userType: 'Vendor', + gender: 'Male', + phoneNumber: '18090296347', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'VENDOR', +}; +const sampleBuyer: UserInterface = { + id: buyerId, + firstName: 'buyer', + lastName: 'user', + email: process.env.TEST_BUYER_EMAIL, + password: process.env.TEST_USER_PASS, + userType: 'Buyer', + gender: 'Male', + phoneNumber: '6380996347', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'BUYER', +}; +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct = { + id: productId, + name: 'test product', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor, + categories: [sampleCat], +}; +const sampleOrder = { + id: orderId, + totalPrice: 400, + quantity: 2, + orderDate: new Date(), + buyer: sampleBuyer, + orderStatus: 'received', + address: 'Rwanda, Kigali, KK20st', +}; +const sampleOrderItem = { + id: orderItemId, + price: 200, + quantity: 2, + order: sampleOrder, + product: sampleProduct, +}; +const sampleVendorOrder = { + id: vendorOrderId, + totalPrice: 400, + quantity: 2, + vendor: sampleVendor, + order: sampleOrder, + buyer: sampleBuyer, + orderStatus: 'pending', +}; +const sampleVendorOrderItem = { + id: vendorOrderItemId, + "price/unit": 200, + quantity: 2, + order: sampleVendorOrder, + product: sampleProduct, +}; + +let userRepository: Repository; +let orderRepository: Repository; +let transactionRepository: Repository; + +beforeAll(async () => { + const connection = await dbConnection(); + if (!connection) { + console.error('Failed to connect to the database'); + return; + } + + userRepository = connection.getRepository(User); + orderRepository = connection.getRepository(Order); + transactionRepository = connection.getRepository(Transaction); + + const categoryRepository = connection.getRepository(Category); + await categoryRepository.save(sampleCat); + + await userRepository.save([sampleAdmin, sampleVendor, sampleVendor2, sampleBuyer]); + + const productRepository = connection.getRepository(Product); + await productRepository.save(sampleProduct); + + await orderRepository.save(sampleOrder); + + const orderItemRepository = connection.getRepository(OrderItem); + await orderItemRepository.save(sampleOrderItem); + + const vendorOrderRepository = connection.getRepository(VendorOrders); + await vendorOrderRepository.save(sampleVendorOrder); + + const vendorOrderItemRepository = connection.getRepository(VendorOrderItem); + await vendorOrderItemRepository.save(sampleVendorOrderItem); +}); + +afterAll(async () => { + await cleanDatabase(); + const connection = getConnection(); + if (connection.isConnected) { + await connection.close(); + } + server.close(); +}); + +describe('User Entity', () => { + it('should validate a valid user', async () => { + const user = userRepository.create({ + id: uuid(), + firstName: 'John', + lastName: 'Doe', + email: process.env.TEST_SAMPLE_BUYER_EMAIL, + password: process.env.TEST_USER_PASS, + gender: 'male', + phoneNumber: '1234567890', + verified: true, + status: 'active', + userType: 'Buyer', + twoFactorEnabled: false, + accountBalance: 0.0, + }); + + const errors = await validate(user); + expect(errors.length).toBe(0); + + const savedUser = await userRepository.save(user); + expect(savedUser.id).toBeDefined(); + expect(savedUser.createdAt).toBeDefined(); + expect(savedUser.updatedAt).toBeDefined(); + }); + + it('should not validate a user with missing required fields', async () => { + const user = new User(); + + const errors = await validate(user); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should set the role based on userType', async () => { + const user = userRepository.create({ + id: vendorOrder2Id, + firstName: 'Jane', + lastName: 'Doe', + email: process.env.TEST_VENDOR2_EMAIL, + password: process.env.TEST_USER_PASS, + gender: 'female', + phoneNumber: '0987654321', + userType: 'Vendor', + }); + + await userRepository.save(user); + expect(user.role).toBe('VENDOR'); + }); + + it('should handle relationships correctly', async () => { + const user = userRepository.create({ + id: uuid(), + firstName: 'Alice', + lastName: 'Smith', + email: 'alice.smith@example.com', + password: process.env.TEST_USER_PASS, + gender: 'female', + phoneNumber: '1122334455', + userType: 'Buyer', + }); + + const savedUser = await userRepository.save(user); + + const order = orderRepository.create({ + id: order2Id, + totalPrice: 400, + quantity: 2, + orderDate: new Date(), + buyer: savedUser, + orderStatus: 'order placed', + address: 'Rwanda, Kigali, KK20st', + }); + + const savedOrder = await orderRepository.save(order); + + const transaction = transactionRepository.create({ + id: uuid(), + order: savedOrder, + user: savedUser, + product: sampleProduct, + amount: 400, + previousBalance: 0, + currentBalance: 400, + type: 'credit', + description: 'order placed', + }); + + await transactionRepository.save(transaction); + + const foundUser = await userRepository.findOne({ + where: { id: savedUser.id }, + relations: ['orders', 'transactions'], + }); + + expect(foundUser).toBeDefined(); + expect(foundUser?.orders.length).toBe(1); + expect(foundUser?.transactions.length).toBe(1); + }); +}); + diff --git a/src/__test__/userStatus.test.ts b/src/__test__/userStatus.test.ts index 69e892a..b92b48e 100644 --- a/src/__test__/userStatus.test.ts +++ b/src/__test__/userStatus.test.ts @@ -68,7 +68,7 @@ describe('POST /user/deactivate', () => { .send({ email: `${testUser.email}` }); expect(response.status).toBe(200); expect(response.body.message).toBe('User deactivated successfully'); - }, 10000); + }, 30000); it('should return 404 when email is not submitted', async () => { const token = jwt.sign(data, jwtSecretKey); diff --git a/src/__test__/vendorProduct.test.ts b/src/__test__/vendorProduct.test.ts index d8fc0a5..dc75565 100644 --- a/src/__test__/vendorProduct.test.ts +++ b/src/__test__/vendorProduct.test.ts @@ -470,4 +470,4 @@ describe('Vendor product management tests', () => { expect(response.status).toBe(400); }); }); -}); +}); \ No newline at end of file diff --git a/src/__test__/wishList.test.ts b/src/__test__/wishList.test.ts index 6658853..23f2609 100644 --- a/src/__test__/wishList.test.ts +++ b/src/__test__/wishList.test.ts @@ -201,4 +201,4 @@ describe('Wish list management tests', () => { expect(response.body.message).toBe('All products removed successfully'); }); }); -}); +}); \ No newline at end of file diff --git a/src/controllers/feedbackController.ts b/src/controllers/feedbackController.ts new file mode 100644 index 0000000..0cbce14 --- /dev/null +++ b/src/controllers/feedbackController.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { createFeedbackService } from '../services/feedbackServices/createFeedback'; +import { updateFeedbackService } from '../services/feedbackServices/updateFeedback'; +import { deleteFeedbackService } from '../services/feedbackServices/deleteFeedback'; +import { adminDeleteFeedbackService } from '../services/feedbackServices/adminDeleteFeedback'; + +export const createFeedback = async (req: Request, res: Response) => { + await createFeedbackService(req, res); +}; + +export const updateFeedback = async (req: Request, res: Response) => { + await updateFeedbackService(req, res); +}; + +export const deleteFeedback = async (req: Request, res: Response) => { + await deleteFeedbackService(req, res); +}; + +export const adminDeleteFeedback = async (req: Request, res: Response) => { + await adminDeleteFeedbackService(req, res); +}; diff --git a/src/controllers/productController.ts b/src/controllers/productController.ts index 1cd895a..05aa5a3 100644 --- a/src/controllers/productController.ts +++ b/src/controllers/productController.ts @@ -10,7 +10,8 @@ import { productStatusServices, viewSingleProduct, searchProductService, - listAllProductsService, + listAllProductsService, + confirmPayment, } from '../services'; export const readProduct = async (req: Request, res: Response) => { @@ -70,3 +71,6 @@ export const searchProduct = async (req: Request, res: Response) => { res.status(500).json({ error: 'Internal Server Error' }); } }; +export const Payment = async (req: Request, res: Response) => { + await confirmPayment(req, res); +}; diff --git a/src/entities/Feedback.ts b/src/entities/Feedback.ts new file mode 100644 index 0000000..6de9058 --- /dev/null +++ b/src/entities/Feedback.ts @@ -0,0 +1,30 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from './User'; +import { Product } from './Product'; +import { IsNotEmpty } from 'class-validator'; +import { Order } from './Order'; + +@Entity() +export class Feedback { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column('text') + comment!: string; + + @ManyToOne(() => User, user => user.feedbacks) + user!: User; + + @ManyToOne(() => Product, product => product.feedbacks) + product!: Product; + + @ManyToOne(() => Order, order => order.feedbacks) + order!: Order; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/Order.ts b/src/entities/Order.ts index 47649a7..71fbb63 100644 --- a/src/entities/Order.ts +++ b/src/entities/Order.ts @@ -11,6 +11,8 @@ import { IsNotEmpty, IsNumber, IsDate, IsIn } from 'class-validator'; import { User } from './User'; import { OrderItem } from './OrderItem'; import { Transaction } from './transaction'; +import { Feedback } from './Feedback'; + @Entity() export class Order { @@ -33,6 +35,10 @@ export class Order { @OneToMany(() => Transaction, transaction => transaction.order) transactions!: Transaction[]; + + @OneToMany(() => Feedback, feedback => feedback.order) + feedbacks!: Feedback[]; + @Column({ default: 'order placed' }) @IsNotEmpty() @IsIn([ diff --git a/src/entities/Product.ts b/src/entities/Product.ts index e144a04..ae027ef 100644 --- a/src/entities/Product.ts +++ b/src/entities/Product.ts @@ -19,11 +19,12 @@ import { Order } from './Order'; import { Coupon } from './coupon'; import { OrderItem } from './OrderItem'; import { VendorOrderItem } from './VendorOrderItem'; +import { Feedback } from './Feedback'; @Entity() @Unique(['id']) export class Product { - static query () { + static query() { throw new Error('Method not implemented.'); } @PrimaryGeneratedColumn('uuid') @@ -39,6 +40,8 @@ export class Product { @OneToMany(() => VendorOrderItem, vendorOrderItems => vendorOrderItems.product) vendorOrderItems!: VendorOrderItem[]; + @OneToMany(() => Feedback, feedback => feedback.product) + feedbacks!: Feedback[]; @OneToOne(() => Coupon, (coupons: any) => coupons.product) @JoinColumn() diff --git a/src/entities/User.ts b/src/entities/User.ts index eebd104..751de17 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -1,3 +1,4 @@ + import { Entity, PrimaryGeneratedColumn, @@ -12,6 +13,7 @@ import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator' import { roles } from '../utils/roles'; import { Order } from './Order'; import { Transaction } from './transaction'; +import { Feedback } from './Feedback'; export interface UserInterface { id?: string; @@ -33,6 +35,12 @@ export interface UserInterface { @Entity() @Unique(['email']) export class User { + static lastName(lastName: any) { + throw new Error('Method not implemented.'); + } + static firstName(firstName: any) { + throw new Error('Method not implemented.'); + } @PrimaryGeneratedColumn('uuid') @IsNotEmpty() id!: string; @@ -110,6 +118,8 @@ export class User { @Column({ type: 'numeric', precision: 24, scale: 2, default: 0 }) accountBalance!: number; + @OneToMany(() => Feedback, feedback => feedback.product) + feedbacks!: Feedback[]; @BeforeInsert() setRole (): void { diff --git a/src/entities/transaction.ts b/src/entities/transaction.ts index 0f7b0ea..d475812 100644 --- a/src/entities/transaction.ts +++ b/src/entities/transaction.ts @@ -58,4 +58,4 @@ export class Transaction { @UpdateDateColumn() updatedAt!: Date; -} +} \ No newline at end of file diff --git a/src/routes/ProductRoutes.ts b/src/routes/ProductRoutes.ts index 614eaaf..3ab9f95 100644 --- a/src/routes/ProductRoutes.ts +++ b/src/routes/ProductRoutes.ts @@ -1,6 +1,6 @@ import { RequestHandler, Router } from 'express'; -import { productStatus, searchProduct } from '../controllers/index'; +import { productStatus, searchProduct, } from '../controllers/index'; import { hasRole } from '../middlewares/roleCheck'; import upload from '../middlewares/multer'; import { authMiddleware } from '../middlewares/verifyToken'; @@ -18,7 +18,7 @@ import { createOrder, getOrders, updateOrder, - getOrdersHistory, + getOrdersHistory,Payment, getSingleVendorOrder, getVendorOrders, updateVendorOrder, @@ -26,7 +26,6 @@ import { getSingleBuyerVendorOrder, updateBuyerVendorOrder, } from '../controllers'; - const router = Router(); router.get('/all', listAllProducts); router.get('/recommended', authMiddleware as RequestHandler, hasRole('BUYER'), getRecommendedProducts); @@ -54,5 +53,6 @@ router.put('/vendor/orders/:id', authMiddleware as RequestHandler, hasRole('VEND router.get('/admin/orders', authMiddleware as RequestHandler, hasRole('ADMIN'), getBuyerVendorOrders); router.get('/admin/orders/:id', authMiddleware as RequestHandler, hasRole('ADMIN'), getSingleBuyerVendorOrder); router.put('/admin/orders/:id', authMiddleware as RequestHandler, hasRole('ADMIN'), updateBuyerVendorOrder); +router.post('/payment/:id', authMiddleware as RequestHandler, hasRole('BUYER'), Payment) -export default router; +export default router; \ No newline at end of file diff --git a/src/routes/feedbackRoutes.ts b/src/routes/feedbackRoutes.ts new file mode 100644 index 0000000..3ada81b --- /dev/null +++ b/src/routes/feedbackRoutes.ts @@ -0,0 +1,19 @@ +import { RequestHandler, Router } from 'express'; +import { + createFeedback, + updateFeedback, + deleteFeedback, + adminDeleteFeedback +} from '../controllers/feedbackController' +import { authMiddleware } from '../middlewares/verifyToken'; +import { hasRole } from '../middlewares/roleCheck'; + + +const router = Router(); + +router.post('/:productId/new', authMiddleware as RequestHandler, hasRole('BUYER'), createFeedback); +router.put('/update/:feedbackId', authMiddleware as RequestHandler, hasRole('BUYER'), updateFeedback ); +router.delete('/delete/:feedbackId', authMiddleware as RequestHandler, hasRole('BUYER'), deleteFeedback); +router.delete('/admin/delete/:feedbackId', authMiddleware as RequestHandler, hasRole('ADMIN'), adminDeleteFeedback ); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 6f632d6..ff31605 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,10 +1,12 @@ -import { Request, Response, Router } from 'express'; -import { responseSuccess } from '../utils/response.utils'; +import { Request, RequestHandler, Response, Router } from 'express'; +import { responseServerError, responseSuccess } from '../utils/response.utils'; import userRoutes from './UserRoutes'; import productRoutes from './ProductRoutes'; import wishListRoutes from './wishListRoute'; import couponRoute from './couponRoutes'; import cartRoutes from './CartRoutes'; +import feedbackRoute from './feedbackRoutes'; +import { authMiddleware } from '../middlewares/verifyToken'; const router = Router(); @@ -17,5 +19,22 @@ router.use('/product', productRoutes); router.use('/wish-list', wishListRoutes); router.use('/cart', cartRoutes); router.use('/coupons', couponRoute); +router.use('/feedback', feedbackRoute); -export default router; +// ROUTES FOR TESTING PURPOSE +router.get('/test', (req: Request, res: Response) => { + res.status(200).json({ message: 'Route works!' }); +}); +router.post('/test/posting', (req: Request, res: Response) =>{ + return responseSuccess(res, 200, req.body); +}); + +router.get('/test/secure', authMiddleware as RequestHandler, (req: Request, res: Response) =>{ + responseSuccess(res, 200, 'This is a secured route.'); +}); + +router.get('/test/error', (req: Request, res: Response) => { + responseServerError(res, 'This is server error route.'); +}); + +export default router; \ No newline at end of file diff --git a/src/services/feedbackServices/adminDeleteFeedback.ts b/src/services/feedbackServices/adminDeleteFeedback.ts new file mode 100644 index 0000000..7bf6261 --- /dev/null +++ b/src/services/feedbackServices/adminDeleteFeedback.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const adminDeleteFeedbackService = async (req: Request, res: Response) => { + const { feedbackId } = req.params; + + try { + const feedbackRepository = getRepository(Feedback); + const feedback = await feedbackRepository.findOne({ + where: { id: feedbackId }, + }); + + if (!feedback) { + return responseError(res, 404, 'Feedback not found'); + } + + await feedbackRepository.remove(feedback); + + return responseSuccess(res, 200, 'Feedback successfully removed'); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/feedbackServices/createFeedback.ts b/src/services/feedbackServices/createFeedback.ts new file mode 100644 index 0000000..fa731f3 --- /dev/null +++ b/src/services/feedbackServices/createFeedback.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { Order } from '../../entities/Order'; + +interface AuthRequest extends Request { + user?: User; +} + +export const createFeedbackService = async (req: Request, res: Response) => { + const { productId } = req.params; + const { comment, orderId } = req.body; + + try { + const feedbackRepository = getRepository(Feedback); + const productRepository = getRepository(Product); + const orderRepository = getRepository(Order); + if (!orderId) { + return responseError(res, 404, `Your feedback can't be recorded at this time Your order doesn't exist `); + } + const product = await productRepository.findOne({ where: { id: productId } }); + if (!product) { + return responseError(res, 404, `Your feedback can't be recorded at this time product not found`); + } + const order = await orderRepository.findBy({ id: orderId, orderStatus: 'completed', buyer: { id: req.user?.id }, orderItems: { product: { id: productId } } }) + if (!order.length) { + return responseError(res, 404, `Your feedback can't be recorded at this time Your order haven't been completed yet or doesn't contain this product`); + } + + const feedback = new Feedback(); + feedback.comment = comment; + feedback.user = req.user as User; + feedback.product = product; + + await feedbackRepository.save(feedback); + + return responseSuccess(res, 201, 'Feedback created successfully', feedback); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/feedbackServices/deleteFeedback.ts b/src/services/feedbackServices/deleteFeedback.ts new file mode 100644 index 0000000..5de4ea0 --- /dev/null +++ b/src/services/feedbackServices/deleteFeedback.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const deleteFeedbackService = async (req: Request, res: Response) => { + const { feedbackId } = req.params; + + try { + const feedbackRepository = getRepository(Feedback); + const feedback = await feedbackRepository.findOne({ + where: { id: feedbackId, + user: {id: req?.user?.id }, + } + }); + + if (!feedback) { + return responseError(res, 404, 'Feedback not found'); + } + + await feedbackRepository.remove(feedback); + + return responseSuccess(res, 200, 'Feedback successfully removed'); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/feedbackServices/updateFeedback.ts b/src/services/feedbackServices/updateFeedback.ts new file mode 100644 index 0000000..18258c2 --- /dev/null +++ b/src/services/feedbackServices/updateFeedback.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { User } from '../../entities/User'; + +export const updateFeedbackService = async (req: Request, res: Response) => { + const { feedbackId } = req.params; + const { comment } = req.body; + + try { + const feedbackRepository = getRepository(Feedback); + + const feedback = await feedbackRepository.findOne({ + where: { + id: feedbackId, + user: { id: req?.user?.id }, + }, + }); + + if (!feedback) { + return responseError(res, 404, 'You are not allowed to remove this feedback or you are not allowed to edit this feedback'); + } + + feedback.comment = comment; + await feedbackRepository.save(feedback); + + return responseSuccess(res, 200, 'Feedback updated successfully', feedback); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/index.ts b/src/services/index.ts index 12d0aa7..08bdbe4 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -21,6 +21,7 @@ export * from './productServices/listAllProductsService'; export * from './productServices/productStatus'; export * from './productServices/viewSingleProduct'; export * from './productServices/searchProduct'; +export * from './productServices/payment' // Buyer wishlist services export * from './wishListServices/addProduct'; diff --git a/src/services/orderServices/createOrder.ts b/src/services/orderServices/createOrder.ts index 30cbb4a..7e1916e 100644 --- a/src/services/orderServices/createOrder.ts +++ b/src/services/orderServices/createOrder.ts @@ -59,15 +59,6 @@ export const createOrderService = async (req: Request, res: Response) => { orderItem.quantity = item.quantity; orderItems.push(orderItem); } - - if (!buyer.accountBalance || buyer.accountBalance < totalPrice) { - return sendErrorResponse(res, 400, 'Not enough funds to perform this transaction'); - } - - const previousBalance = buyer.accountBalance; - buyer.accountBalance -= totalPrice; - const currentBalance = buyer.accountBalance; - const newOrder = new Order(); newOrder.buyer = buyer; newOrder.totalPrice = totalPrice; @@ -94,8 +85,6 @@ export const createOrderService = async (req: Request, res: Response) => { orderTransaction.user = buyer; orderTransaction.order = newOrder; orderTransaction.amount = totalPrice; - orderTransaction.previousBalance = previousBalance; - orderTransaction.currentBalance = currentBalance; orderTransaction.type = 'debit'; orderTransaction.description = 'Purchase of products'; await transactionalEntityManager.save(Transaction, orderTransaction); @@ -105,6 +94,7 @@ export const createOrderService = async (req: Request, res: Response) => { }); const orderResponse = { + id: newOrder.id, fullName: `${newOrder.buyer.firstName} ${newOrder.buyer.lastName}`, email: newOrder.buyer.email, products: orderItems.map(item => ({ @@ -174,7 +164,7 @@ const saveVendorRelatedOrder = async (order: Order, CartItem: CartItem[]) => { newVendorOrders.vendor = product.vendor; newVendorOrders.vendorOrderItems = [orderItem]; newVendorOrders.order = order; - newVendorOrders.totalPrice = +product.newPrice * item.quantity; + newVendorOrders.totalPrice = product.newPrice * item.quantity; vendorOrders = newVendorOrders; } @@ -183,4 +173,4 @@ const saveVendorRelatedOrder = async (order: Order, CartItem: CartItem[]) => { } catch (error) { console.log((error as Error).message); } -}; +}; \ No newline at end of file diff --git a/src/services/orderServices/getOrderTransactionHistory.ts b/src/services/orderServices/getOrderTransactionHistory.ts index 74ae473..6bd0b17 100644 --- a/src/services/orderServices/getOrderTransactionHistory.ts +++ b/src/services/orderServices/getOrderTransactionHistory.ts @@ -23,8 +23,6 @@ export const getTransactionHistoryService = async (req: Request, res: Response) id: transaction.id, amount: transaction.amount, type: transaction.type, - previousBalance: transaction.previousBalance, - currentBalance: transaction.currentBalance, description: transaction.description, createdAt: transaction.createdAt, order: transaction.order diff --git a/src/services/orderServices/updateOrderService.ts b/src/services/orderServices/updateOrderService.ts index 82a043a..bfddf1f 100644 --- a/src/services/orderServices/updateOrderService.ts +++ b/src/services/orderServices/updateOrderService.ts @@ -93,7 +93,7 @@ export const updateOrderService = async (req: Request, res: Response) => { return sendSuccessResponse(res, 200, 'Order updated successfully', orderResponse); }); } catch (error) { - console.error('Error updating order:', error); + console.error('Error updating order:', (error as Error).message); return sendErrorResponse(res, 500, (error as Error).message); } }; @@ -102,9 +102,6 @@ async function processRefund (order: Order, entityManager: EntityManager) { const buyer = order.buyer; // Refund buyer - const previousBalance = buyer.accountBalance; - buyer.accountBalance += order.totalPrice; - const currentBalance = buyer.accountBalance; await entityManager.save(buyer); // Record refund transaction @@ -112,8 +109,6 @@ async function processRefund (order: Order, entityManager: EntityManager) { refundTransaction.user = buyer; refundTransaction.order = order; refundTransaction.amount = order.totalPrice; - refundTransaction.previousBalance = previousBalance; - refundTransaction.currentBalance = currentBalance; refundTransaction.type = 'credit'; refundTransaction.description = 'Refund for cancelled or returned order'; await entityManager.save(refundTransaction); diff --git a/src/services/productServices/getRecommendedProductsService.ts b/src/services/productServices/getRecommendedProductsService.ts index fde015d..533dcd9 100644 --- a/src/services/productServices/getRecommendedProductsService.ts +++ b/src/services/productServices/getRecommendedProductsService.ts @@ -30,6 +30,7 @@ export const getRecommendedProductsService = async (req: Request, res: Response) .createQueryBuilder('product') .leftJoinAndSelect('product.categories', 'category') .leftJoinAndSelect('product.vendor', 'vendor') + .leftJoinAndSelect('product.feedbacks', 'feedbacks') .where('1 = 1'); if (condition.categories && condition.categories.length > 0) { diff --git a/src/services/productServices/listAllProductsService.ts b/src/services/productServices/listAllProductsService.ts index f39c7bb..4429e89 100644 --- a/src/services/productServices/listAllProductsService.ts +++ b/src/services/productServices/listAllProductsService.ts @@ -20,7 +20,7 @@ export const listAllProductsService = async (req: Request, res: Response) => { }, skip, take: limit, - relations: ['categories', 'vendor'], + relations: ['categories', 'vendor', 'feedbacks'], select: { vendor: { id: true, diff --git a/src/services/productServices/payment.ts b/src/services/productServices/payment.ts new file mode 100644 index 0000000..b613296 --- /dev/null +++ b/src/services/productServices/payment.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import { Cart } from '../../entities/Cart'; // Import your Cart entity +import { Order } from '../../entities/Order'; // Import your Order entity +import { getRepository, getTreeRepository } from 'typeorm'; +import dotenv from 'dotenv'; +import Stripe from 'stripe'; +dotenv.config(); +const stripeInstance = new Stripe(process.env.STRIPE_SECRET_KEY as string, { + apiVersion: "2024-04-10", +}); + +export const confirmPayment = async (req: Request, res: Response) => { + try { + const { payment_method } = req.body; + const cartId = req.params.cartId; // Get the cart ID from the params + + const cartRepository = getRepository(Cart); + const orderRepository = getTreeRepository(Order) + const cart = await cartRepository.findOne({where: {id : cartId}}); + if (!cart) { + return res.status(404).json({ error: 'Cart not found.' }); + } + const order = await orderRepository.findOne({ where: { buyer: cart.user } }); + if (!order) { + return res.status(404).json({ error: 'order not found.' }); + } + + const paymentIntent = await stripeInstance.paymentIntents.create({ + amount: cart.totalAmount, // Convert total to cents + currency: 'usd', + description: `Order #${cartId}`, + return_url: 'https://frontend-website.com/success', + confirm: true, + payment_method, + }); + + order.orderStatus = 'awaiting shipment'; + await orderRepository.save(order); + + + if (paymentIntent.status === 'succeeded') { + // Payment succeeded + res.status(200).json({ message: 'Payment successful!' }); + } else { + // Payment failed + res.status(400).json({ error: 'Payment failed.' }); + } + } catch (error) { + console.error('Error confirming payment:', error); + res.status(500).json({ error: 'Something went wrong' }); + } +}; \ No newline at end of file diff --git a/src/services/productServices/readProduct.ts b/src/services/productServices/readProduct.ts index b3c244d..2836b21 100644 --- a/src/services/productServices/readProduct.ts +++ b/src/services/productServices/readProduct.ts @@ -20,7 +20,7 @@ export const readProductsService = async (req: Request, res: Response) => { }, skip, take: limit, - relations: ['categories', 'vendor'], + relations: ['categories', 'vendor', 'feedbacks'], select: { vendor: { id: true, diff --git a/src/services/productServices/viewSingleProduct.ts b/src/services/productServices/viewSingleProduct.ts index 29ac167..be9764d 100644 --- a/src/services/productServices/viewSingleProduct.ts +++ b/src/services/productServices/viewSingleProduct.ts @@ -13,7 +13,7 @@ export const viewSingleProduct = async (req: Request, res: Response) => { } if (productId) { const products = getRepository(Product); - const product = await products.findOneBy({ id: productId }); + const product = await products.findOne({ where: { id: productId }, relations: ['categories', 'vendor', 'feedbacks'], }); if (!product) { return res.status(404).send({ status: 'error', message: 'Product not found' });