diff --git a/.vscode/settings.json b/.vscode/settings.json index 5480842..bad1729 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { "kiroAgent.configureMCP": "Disabled" -} \ No newline at end of file +} diff --git a/docs/backend/data-flow.md b/docs/backend/data-flow.md new file mode 100644 index 0000000..cbf260b --- /dev/null +++ b/docs/backend/data-flow.md @@ -0,0 +1,28 @@ +# Data Flow + +The standard request lifecycle (e.g., fetching or updating a contract) follows this step-by-step flow: + +1. **Client Request:** + A request hits an endpoint (e.g., `GET /api/v1/contracts`). + +2. **Middleware (Validation/Auth):** + - Global middleware parses JSON (`express.json()`) and adds security headers (`helmet()`). + - Route-specific middleware (`validateSchema`) validates the request payload structure. + +3. **Controller (`ContractsController`):** + - Receives the validated request. + - Extracts parameters (e.g., contract ID). + - Calls the corresponding Service method. + +4. **Service/Business Logic (`ContractsService`):** + - Executes business rules. + - May call the Database (for metadata) and the `SorobanService` (to fetch on-chain state). + - Aggregates the results. + +5. **Response:** + - The Controller receives the aggregated data from the Service. + - Formats the HTTP response and sends it back to the client. + +## Integration Points +- **Stellar/Soroban Network:** Interaction via Stellar SDK/Soroban RPC. Reads smart contract states and events. +- **Authentication Provider:** Signature verification to authenticate users based on their Stellar keypairs. diff --git a/docs/backend/modules.md b/docs/backend/modules.md new file mode 100644 index 0000000..d336ed6 --- /dev/null +++ b/docs/backend/modules.md @@ -0,0 +1,21 @@ +# Module Breakdown + +Based on the application's domain (freelancer escrow, Soroban integration), the backend is organized into the following core modules: + +## Contracts Module (`src/modules/contracts/`) +- **Responsibilities:** + - Manage off-chain metadata for escrow contracts. + - Synchronize state with Soroban smart contracts. +- **Dependencies:** + - Depends on `SorobanService` for on-chain state queries. + +## Reputation Module (`src/modules/reputation/`) +- **Responsibilities:** + - Track and calculate user/freelancer reputation scores based on completed contracts and reviews. +- **Dependencies:** + - Relies on Contract data to verify completed work. + +## Soroban Integration Module (`src/services/soroban.service.ts`) +- **Responsibilities:** + - Bridge the backend with the Stellar network. + - Read contract states, listen for events, and optionally submit transactions securely. diff --git a/err2.txt b/err2.txt new file mode 100644 index 0000000..2728f9e --- /dev/null +++ b/err2.txt @@ -0,0 +1,46 @@ +FAIL src/controllers/contracts.controller.test.ts + ContractsController fallback errors + × should catch error in getContracts and call next() (5 ms) + × should catch error in createContract and call next() (1 ms) + + ● ContractsController fallback errors › should catch error in getContracts and call next() + + expect(jest.fn()).toHaveBeenCalledWith(...expected) + + Expected: [Error: DB Down] + + Number of calls: 0 + +   33 | await ContractsController.getContracts(mockRequest as Request, mockResponse as Response, mockNext); +  34 | + > 35 | expect(mockNext).toHaveBeenCalledWith(mockError); +  | ^ +  36 | }); +  37 | +  38 | it('should catch error in createContract and call next()', async () => { + + at Object. (src/controllers/contracts.controller.test.ts:35:22) + + ● ContractsController fallback errors › should catch error in createContract and call next() + + expect(jest.fn()).toHaveBeenCalledWith(...expected) + + Expected: [Error: Creation failed] + + Number of calls: 0 + +   42 | await ContractsController.createContract(mockRequest as Request, mockResponse as Response, mockNext); +  43 | + > 44 | expect(mockNext).toHaveBeenCalledWith(mockError); +  | ^ +  45 | }); +  46 | }); +  47 | + + at Object. (src/controllers/contracts.controller.test.ts:44:22) + +Test Suites: 1 failed, 1 total +Tests: 2 failed, 2 total +Snapshots: 0 total +Time: 2.312 s, estimated 3 s +Ran all test suites matching /src\\controllers\\contracts.controller.test.ts/i. diff --git a/out1.txt b/out1.txt new file mode 100644 index 0000000..e69de29 diff --git a/out2.txt b/out2.txt new file mode 100644 index 0000000..e69de29 diff --git a/results.json b/results.json new file mode 100644 index 0000000..caf80b0 Binary files /dev/null and b/results.json differ diff --git a/src/controllers/contracts.controller.test.ts b/src/controllers/contracts.controller.test.ts new file mode 100644 index 0000000..e66ada4 --- /dev/null +++ b/src/controllers/contracts.controller.test.ts @@ -0,0 +1,58 @@ +import { Request, Response, NextFunction } from 'express'; + +const mockGetAllContracts = jest.fn(); +const mockCreateContract = jest.fn(); + +jest.mock('../services/contracts.service', () => { + return { + ContractsService: jest.fn().mockImplementation(() => { + return { + getAllContracts: mockGetAllContracts, + createContract: mockCreateContract, + }; + }), + }; +}); + +import { ContractsController } from './contracts.controller'; + +describe('ContractsController fallback errors', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + mockRequest = { + body: { title: 'Test Contract' } + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + mockNext = jest.fn(); + mockGetAllContracts.mockClear(); + mockCreateContract.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should catch error in getContracts and call next()', async () => { + const mockError = new Error('DB Down'); + mockGetAllContracts.mockRejectedValue(mockError); + + await ContractsController.getContracts(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(mockError); + }); + + it('should catch error in createContract and call next()', async () => { + const mockError = new Error('Creation failed'); + mockCreateContract.mockRejectedValue(mockError); + + await ContractsController.createContract(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(mockError); + }); +}); diff --git a/src/controllers/contracts.controller.ts b/src/controllers/contracts.controller.ts new file mode 100644 index 0000000..5abec86 --- /dev/null +++ b/src/controllers/contracts.controller.ts @@ -0,0 +1,40 @@ +import { Request, Response, NextFunction } from 'express'; +import { ContractsService } from '../services/contracts.service'; +import { CreateContractDto } from '../modules/contracts/dto/contract.dto'; + +const contractsService = new ContractsService(); + +/** + * @dev Presentation layer for Contracts. + * Handles HTTP requests, extracts parameters, and formulates responses. + * Delegates core logic to the ContractsService. + */ +export class ContractsController { + + /** + * GET /api/v1/contracts + * Fetch a list of all escrow contracts. + */ + public static async getContracts(req: Request, res: Response, next: NextFunction) { + try { + const contracts = await contractsService.getAllContracts(); + res.status(200).json({ status: 'success', data: contracts }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/v1/contracts + * Create a new escrow contract metadata entry. + */ + public static async createContract(req: Request, res: Response, next: NextFunction) { + try { + const data: CreateContractDto = req.body; + const newContract = await contractsService.createContract(data); + res.status(201).json({ status: 'success', data: newContract }); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/contracts.integration.test.ts b/src/controllers/contracts.integration.test.ts new file mode 100644 index 0000000..db195ed --- /dev/null +++ b/src/controllers/contracts.integration.test.ts @@ -0,0 +1,69 @@ +import request from 'supertest'; +import app from '../index'; + +describe('Contracts API Integration Tests', () => { + describe('GET /health', () => { + it('should return health status', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok', service: 'talenttrust-backend' }); + }); + }); + + describe('GET /api/v1/contracts', () => { + it('should return a list of contracts (initially empty)', async () => { + const res = await request(app).get('/api/v1/contracts'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: 'success', data: [] }); + }); + }); + + describe('POST /api/v1/contracts', () => { + it('should create a new contract with valid input', async () => { + const payload = { + title: 'Valid Contract Title', + description: 'This is a valid long enough description.', + budget: 1000 + }; + + const res = await request(app) + .post('/api/v1/contracts') + .send(payload); + + expect(res.status).toBe(201); + expect(res.body.status).toBe('success'); + expect(res.body.data.title).toBe(payload.title); + expect(res.body.data.id).toBeDefined(); + }); + + it('should return 400 validation error with invalid input (missing title)', async () => { + const payload = { + description: 'This is a valid long enough description.', + budget: 1000 + }; + + const res = await request(app) + .post('/api/v1/contracts') + .send(payload); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + expect(res.body.message).toBe('Validation failed'); + }); + + it('should return 400 validation error with invalid budget (negative)', async () => { + const payload = { + title: 'Valid Contract Title', + description: 'This is a valid long enough description.', + budget: -50 + }; + + const res = await request(app) + .post('/api/v1/contracts') + .send(payload); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + }); + }); +}); diff --git a/src/middleware/error.middleware.test.ts b/src/middleware/error.middleware.test.ts new file mode 100644 index 0000000..7fa1639 --- /dev/null +++ b/src/middleware/error.middleware.test.ts @@ -0,0 +1,65 @@ +import { Request, Response, NextFunction } from 'express'; +import { errorHandler } from './error.middleware'; + +describe('Error Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + mockRequest = {}; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + mockNext = jest.fn(); + // Suppress console.error in tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should handle standard 500 error', () => { + const err = new Error('Test error'); + errorHandler(err, mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: 'error', + statusCode: 500, + message: 'Test error', + }); + }); + + it('should handle custom error status', () => { + const err: any = new Error('Not found'); + err.status = 404; + errorHandler(err, mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: 'error', + statusCode: 404, + message: 'Not found', + }); + }); + + it('should obscure 500 errors in production', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const err = new Error('Sensitive DB Error'); + errorHandler(err, mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: 'error', + statusCode: 500, + message: 'Internal server error', + }); + + process.env.NODE_ENV = originalEnv; + }); +}); diff --git a/src/middleware/error.middleware.ts b/src/middleware/error.middleware.ts new file mode 100644 index 0000000..deee1a6 --- /dev/null +++ b/src/middleware/error.middleware.ts @@ -0,0 +1,26 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * @dev Global error handling middleware. + * Ensures that unexpected errors do not leak stack traces or internal + * logic to the client, especially in production environments. + * + * @param err The error object. + * @param req The Express Request. + * @param res The Express Response. + * @param next The Express NextFunction. + */ +export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => { + const statusCode = err.status || 500; + const message = err.message || 'Internal Server Error'; + + console.error(`[Error] ${statusCode} - ${message}`, err.stack); + + res.status(statusCode).json({ + status: 'error', + statusCode, + message: process.env.NODE_ENV === 'production' && statusCode === 500 + ? 'Internal server error' + : message, + }); +}; diff --git a/src/middleware/validate.middleware.test.ts b/src/middleware/validate.middleware.test.ts new file mode 100644 index 0000000..38f8c47 --- /dev/null +++ b/src/middleware/validate.middleware.test.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { validateSchema } from './validate.middleware'; + +describe('Validate Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + mockRequest = { + body: {}, + query: {}, + params: {} + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + mockNext = jest.fn(); + }); + + it('should validate successfully', async () => { + const schema = z.object({ + body: z.object({ + name: z.string(), + }) + }); + + mockRequest.body = { name: 'Test' }; + const middleware = validateSchema(schema); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should handle ZodError and return 400', async () => { + const schema = z.object({ + body: z.object({ + name: z.string(), + }) + }); + + const middleware = validateSchema(schema); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + status: 'error', + message: 'Validation failed', + })); + }); + + it('should pass non-Zod errors to next()', async () => { + const errorSchema = { + parseAsync: jest.fn().mockRejectedValue(new Error('Generic Error')) + } as any; + + const middleware = validateSchema(errorSchema); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/src/middleware/validate.middleware.ts b/src/middleware/validate.middleware.ts new file mode 100644 index 0000000..5031614 --- /dev/null +++ b/src/middleware/validate.middleware.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodTypeAny, ZodError } from 'zod'; + +/** + * @dev Validation middleware using Zod. + * Validates the incoming Request against a provided Zod schema. + * Prevents injection attacks and ensures payload conformity. + * + * @param schema The Zod schema to validate against (body, query, params). + * @returns An Express middleware function. + */ +export const validateSchema = (schema: ZodTypeAny) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + await schema.parseAsync({ + body: req.body, + query: req.query, + params: req.params, + }); + next(); + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + status: 'error', + message: 'Validation failed', + errors: error.issues, + }); + } + next(error); + } + }; +}; diff --git a/src/modules/contracts/dto/contract.dto.ts b/src/modules/contracts/dto/contract.dto.ts new file mode 100644 index 0000000..70defb0 --- /dev/null +++ b/src/modules/contracts/dto/contract.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const createContractSchema = z.object({ + body: z.object({ + title: z.string().min(5).max(100), + description: z.string().min(10), + freelancerId: z.string().uuid().optional(), + budget: z.number().positive(), + }), +}); + +export type CreateContractDto = z.infer['body']; diff --git a/src/routes/contracts.routes.ts b/src/routes/contracts.routes.ts new file mode 100644 index 0000000..ecba05d --- /dev/null +++ b/src/routes/contracts.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { ContractsController } from '../controllers/contracts.controller'; +import { validateSchema } from '../middleware/validate.middleware'; +import { createContractSchema } from '../modules/contracts/dto/contract.dto'; + +const router = Router(); + +// Configure routes for the Contracts module +router.get('/', ContractsController.getContracts); + +// Enforce Zod input validation on the POST route +router.post( + '/', + validateSchema(createContractSchema), + ContractsController.createContract +); + +export default router; diff --git a/src/services/contracts.service.test.ts b/src/services/contracts.service.test.ts new file mode 100644 index 0000000..b5e8407 --- /dev/null +++ b/src/services/contracts.service.test.ts @@ -0,0 +1,54 @@ +import { ContractsService } from './contracts.service'; +import { SorobanService } from './soroban.service'; + +// Mock the SorobanService to isolate tests +jest.mock('./soroban.service'); + +describe('ContractsService', () => { + let contractsService: ContractsService; + let mockSorobanService: jest.Mocked; + + beforeEach(() => { + mockSorobanService = new SorobanService() as jest.Mocked; + + // In our implementation, ContractsService instantiates its own SorobanService. + // By mocking the module, instances will be mocked automatically. + contractsService = new ContractsService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getAllContracts', () => { + it('should return an empty array initially', async () => { + const contracts = await contractsService.getAllContracts(); + expect(contracts).toEqual([]); + }); + }); + + describe('createContract', () => { + it('should create a contract and call SorobanService.prepareEscrow', async () => { + const contractData = { + title: 'Build a frontend', + description: 'React TS development', + budget: 500 + }; + + const result = await contractsService.createContract(contractData); + + expect(result).toMatchObject({ + title: 'Build a frontend', + description: 'React TS development', + budget: 500, + status: 'PENDING' + }); + expect(result.id).toBeDefined(); + expect(result.createdAt).toBeDefined(); + + // Check if the mock was called correctly + const mockPrepareEscrow = SorobanService.prototype.prepareEscrow as jest.Mock; + expect(mockPrepareEscrow).toHaveBeenCalledWith(result.id, 500); + }); + }); +}); diff --git a/src/services/contracts.service.ts b/src/services/contracts.service.ts new file mode 100644 index 0000000..49f7f9e --- /dev/null +++ b/src/services/contracts.service.ts @@ -0,0 +1,48 @@ +import { CreateContractDto } from '../modules/contracts/dto/contract.dto'; +import { SorobanService } from './soroban.service'; + +/** + * @dev Service layer for managing Freelancer Escrow Contracts. + * Handles business logic, database interactions (mocked for now), + * and orchestration with the Soroban smart contract service. + */ +export class ContractsService { + private sorobanService: SorobanService; + + // Mock database + private contracts: any[] = []; + + constructor() { + this.sorobanService = new SorobanService(); + } + + /** + * Retrieves all contracts, optionally syncing with their on-chain state. + * @returns Array of contract metadata. + */ + public async getAllContracts() { + // In a real app, we would fetch from DB and map to on-chain state if necessary. + return this.contracts; + } + + /** + * Creates a new contract off-chain, preparing it for escrow deposit. + * @param data The contract details conforming to CreateContractDto. + * @returns The newly created contract object. + */ + public async createContract(data: CreateContractDto) { + const newContract = { + id: crypto.randomUUID(), + ...data, + status: 'PENDING', + createdAt: new Date(), + }; + + this.contracts.push(newContract); + + // Simulate notifying the Soroban service to prepare the transaction + await this.sorobanService.prepareEscrow(newContract.id, data.budget); + + return newContract; + } +} diff --git a/src/services/soroban.service.test.ts b/src/services/soroban.service.test.ts new file mode 100644 index 0000000..1f1d3db --- /dev/null +++ b/src/services/soroban.service.test.ts @@ -0,0 +1,25 @@ +import { SorobanService } from './soroban.service'; + +describe('SorobanService', () => { + let sorobanService: SorobanService; + + beforeEach(() => { + sorobanService = new SorobanService(); + // Spy on console.log + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should prepare escrow successfully', async () => { + const result = await sorobanService.prepareEscrow('contract123', 100); + expect(result).toBe(true); + }); + + it('should return escrow status', async () => { + const status = await sorobanService.getEscrowStatus('contract123'); + expect(status).toBe('FUNDED'); + }); +}); diff --git a/src/services/soroban.service.ts b/src/services/soroban.service.ts new file mode 100644 index 0000000..93be849 --- /dev/null +++ b/src/services/soroban.service.ts @@ -0,0 +1,25 @@ +/** + * @dev Service handling Stellar/Soroban smart contract integrations. + * Responsible for calling RPC nodes, managing blockchain states, and parsing events. + */ +export class SorobanService { + + /** + * Prepares the escrow contract state on the blockchain or verifies preconditions. + * @param contractId Internal database reference ID. + * @param amount The escrow amount. + */ + public async prepareEscrow(contractId: string, amount: number): Promise { + // Mock implementation for Stellar network interaction + // console.log(`[SorobanService] Preparing escrow for contract ${contractId} with amount ${amount}`); + return true; + } + + /** + * Reads the current status of the escrow from the smart contract. + * @param contractId Internal database reference ID. + */ + public async getEscrowStatus(contractId: string): Promise { + return 'FUNDED'; + } +} diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..655582c Binary files /dev/null and b/test_output.txt differ