Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ All configuration is managed through `src/config/` and validated at startup. Cop
| `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban JSON-RPC endpoint |
| `SOROBAN_CONTRACT_ID` | *(empty)* | Deployed escrow contract ID |

## API Endpoints

- `GET /health` - Health check
- `GET /api/v1/contracts` - Get contracts
- `GET /api/v1/reputation/:id` - Get freelancer reputation profile
- `PUT /api/v1/reputation/:id` - Update freelancer reputation profile

See [docs/backend/reputation-api.md](docs/backend/reputation-api.md) for detailed Reputation API info.

## Contributing

1. Fork the repo and create a branch: `git checkout -b feature/<ticket>-description`
Expand Down
63 changes: 63 additions & 0 deletions docs/backend/reputation-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Reputation Profile API

This document details the Reputation Profile API within the TalentTrust Backend.

## Overview

The Reputation API provides mechanisms to retrieve and update the reputation of freelancers. It uses a 1-5 rating system with comments and tracks metrics such as `score`, `jobsCompleted`, and `totalRatings`.

## Endpoints

### 1. Retrieve Freelancer Reputation

**GET** `/api/v1/reputation/:freelancerId`

Retrieves the reputation profile of the freelancer. If it doesn't exist, a default empty profile is returned.

**Response (200 OK):**
```json
{
"status": "success",
"data": {
"freelancerId": "fl-12345",
"score": 4.5,
"jobsCompleted": 10,
"totalRatings": 15,
"reviews": [ ... ],
"lastUpdated": "2023-11-01T10:00:00.000Z"
}
}
```

### 2. Update Freelancer Reputation

**PUT** `/api/v1/reputation/:freelancerId`

Adds a new review to the freelancer's profile and automatically recalculates the overall score. Optionally tracks job completion.

**Request Body:**
```json
{
"reviewerId": "client-987",
"rating": 5,
"comment": "Excellent work!",
"jobCompleted": true
}
```

**Response (200 OK):**
```json
{
"status": "success",
"data": {
// Updated profile data
}
}
```

## Security Assumptions and Threat Scenarios

- **Input Validation**: The API ensures rating values strictly fall between 1 and 5. Missing mandatory ID fields are aggressively rejected with a 400 Bad Request.
- **Data Integrity**: Average score computations are protected from division-by-zero bounds during their update cycle.
- **Current Limitation (Mock Storage)**: Since the state is in-memory for the time being, data does not persist across backend restarts. In subsequent releases, data must be securely mapped to a database or immutable ledger (Stellar/Soroban).
- **Authentication**: Currently, the routes are unprotected for testing. In production, these calls must be protected by JWT and role-based checks (e.g., verifying `reviewerId` matches the authenticated client session and that the job relationship actually exists).
55 changes: 55 additions & 0 deletions src/controllers/reputation.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Request, Response } from 'express';
import { ReputationController } from './reputation.controller';
import { ReputationService } from '../services/reputation.service';

jest.mock('../services/reputation.service');

describe('ReputationController', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: jest.Mock;
let statusMock: jest.Mock;

beforeEach(() => {
jsonMock = jest.fn();
statusMock = jest.fn().mockReturnValue({ json: jsonMock });
req = { params: { id: 'user-1' }, body: {} };
res = { status: statusMock } as unknown as Response;
jest.clearAllMocks();
});

describe('getProfile', () => {
it('should return 400 if service throws Freelancer ID is required', () => {
(ReputationService.getProfile as jest.Mock).mockImplementation(() => {
throw new Error('Freelancer ID is required');
});

ReputationController.getProfile(req as Request, res as Response);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith({ status: 'error', message: 'Freelancer ID is required' });
});

it('should return 500 for unknown service errors in getProfile', () => {
(ReputationService.getProfile as jest.Mock).mockImplementation(() => {
throw new Error('Database down');
});

ReputationController.getProfile(req as Request, res as Response);
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith({ status: 'error', message: 'Internal server error' });
});
});

describe('updateProfile', () => {
it('should return 500 for unknown service errors in updateProfile', () => {
req.body = { reviewerId: 'client-1', rating: 5 };
(ReputationService.updateProfile as jest.Mock).mockImplementation(() => {
throw new Error('Unexpected failure');
});

ReputationController.updateProfile(req as Request, res as Response);
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith({ status: 'error', message: 'Internal server error' });
});
});
});
57 changes: 57 additions & 0 deletions src/controllers/reputation.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Request, Response } from 'express';
import { ReputationService } from '../services/reputation.service';
import { UpdateReputationPayload } from '../types/reputation';

/**
* @title Reputation Controller
* @dev NatSpec: Controller handling HTTP requests for the Freelancer Reputation Profile API.
*/
export class ReputationController {
/**
* @notice Get a freelancer's reputation profile
* @param req The Express request containing freelancerId in params
* @param res The Express response object
*/
public static getProfile(req: Request, res: Response): void {
try {
const { id } = req.params;
const profile = ReputationService.getProfile(id);

// We always return a profile, defaulting to an empty one if no rating exists
res.status(200).json({ status: 'success', data: profile });
} catch (error: any) {
if (error.message === 'Freelancer ID is required') {
res.status(400).json({ status: 'error', message: error.message });
} else {
res.status(500).json({ status: 'error', message: 'Internal server error' });
}
}
}

/**
* @notice Update a freelancer's reputation profile with a new review
* @param req The Express request containing freelancerId in params and payload in body
* @param res The Express response object
*/
public static updateProfile(req: Request, res: Response): void {
try {
const { id } = req.params;
const payload: UpdateReputationPayload = req.body;

// Basic input validation handles securely before reaching service
if (!payload || !payload.reviewerId || typeof payload.rating !== 'number') {
res.status(400).json({ status: 'error', message: 'Invalid payload: reviewerId and rating are required' });
return;
}

const updatedProfile = ReputationService.updateProfile(id, payload);
res.status(200).json({ status: 'success', data: updatedProfile });
} catch (error: any) {
if (error.message.includes('required') || error.message.includes('Rating must be between 1 and 5')) {
res.status(400).json({ status: 'error', message: error.message });
} else {
res.status(500).json({ status: 'error', message: 'Internal server error' });
}
}
}
}
39 changes: 39 additions & 0 deletions src/models/reputation.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { reputationStore } from './reputation.store';
import { ReputationProfile } from '../types/reputation';

describe('ReputationStore', () => {
const profile: ReputationProfile = {
freelancerId: 'user-1',
score: 5,
jobsCompleted: 1,
totalRatings: 1,
reviews: [],
lastUpdated: new Date().toISOString()
};

beforeEach(() => {
reputationStore.clear();
});

it('should set and get a profile', () => {
reputationStore.set(profile);
expect(reputationStore.get('user-1')).toEqual(profile);
});

it('should return undefined if profile does not exist', () => {
expect(reputationStore.get('unknown')).toBeUndefined();
});

it('should correctly report if a profile exists (has)', () => {
reputationStore.set(profile);
expect(reputationStore.has('user-1')).toBe(true);
expect(reputationStore.has('user-2')).toBe(false);
});

it('should delete a profile', () => {
reputationStore.set(profile);
expect(reputationStore.has('user-1')).toBe(true);
reputationStore.delete('user-1');
expect(reputationStore.has('user-1')).toBe(false);
});
});
57 changes: 57 additions & 0 deletions src/models/reputation.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ReputationProfile } from '../types/reputation';

/**
* @title Reputation Store
* @dev NatSpec: Mock in-memory persistence layer for Freelancer Reputation Profiles.
*/
class ReputationStore {
private profiles: Map<string, ReputationProfile>;

constructor() {
this.profiles = new Map<string, ReputationProfile>();
}

/**
* @notice Get a profile by freelancerId
* @param id The freelancer identifier
* @return The profile if found, otherwise undefined
*/
public get(id: string): ReputationProfile | undefined {
return this.profiles.get(id);
}

/**
* @notice Set or update a profile
* @param profile The full reputation profile object
*/
public set(profile: ReputationProfile): void {
this.profiles.set(profile.freelancerId, profile);
}

/**
* @notice Check if a profile exists
* @param id The freelancer identifier
* @return True if exists, else false
*/
public has(id: string): boolean {
return this.profiles.has(id);
}

/**
* @notice Delete a profile (useful for tests)
* @param id The freelancer identifier
*/
public delete(id: string): void {
this.profiles.delete(id);
}

/**
* @notice Clear all profiles (useful for tests)
*/
public clear(): void {
this.profiles.clear();
}
}

// Export a singleton instance for simplicity
export const reputationStore = new ReputationStore();
68 changes: 68 additions & 0 deletions src/routes/reputation.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import request from 'supertest';
import app from '../index';
import { reputationStore } from '../models/reputation.store';

describe('Reputation API Integration Tests', () => {
const freelancerId = 'api-user-123';

beforeEach(() => {
reputationStore.clear();
});

describe('GET /api/v1/reputation/:id', () => {
it('should return a default profile for a new user', async () => {
const response = await request(app).get(`/api/v1/reputation/${freelancerId}`);
expect(response.status).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.freelancerId).toBe(freelancerId);
expect(response.body.data.score).toBe(0);
expect(response.body.data.totalRatings).toBe(0);
});
});

describe('PUT /api/v1/reputation/:id', () => {
it('should fail with 400 for invalid payload (missing rating)', async () => {
const response = await request(app)
.put(`/api/v1/reputation/${freelancerId}`)
.send({ reviewerId: 'client-1' });

expect(response.status).toBe(400);
expect(response.body.message).toContain('reviewerId and rating are required');
});

it('should fail with 400 for invalid rating bounds', async () => {
const response = await request(app)
.put(`/api/v1/reputation/${freelancerId}`)
.send({ reviewerId: 'client-1', rating: 10 });

expect(response.status).toBe(400);
expect(response.body.message).toContain('Rating must be between 1 and 5');
});

it('should successfully update and return the new profile', async () => {
const response = await request(app)
.put(`/api/v1/reputation/${freelancerId}`)
.send({ reviewerId: 'client-1', rating: 5, comment: 'Awesome', jobCompleted: true });

expect(response.status).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.score).toBe(5);
expect(response.body.data.jobsCompleted).toBe(1);
});

it('should handle sequential updates correctly via the API', async () => {
await request(app)
.put(`/api/v1/reputation/${freelancerId}`)
.send({ reviewerId: 'client-1', rating: 3, jobCompleted: true });

const response = await request(app)
.put(`/api/v1/reputation/${freelancerId}`)
.send({ reviewerId: 'client-2', rating: 4, jobCompleted: true });

expect(response.status).toBe(200);
expect(response.body.data.score).toBe(3.5);
expect(response.body.data.totalRatings).toBe(2);
expect(response.body.data.jobsCompleted).toBe(2);
});
});
});
17 changes: 17 additions & 0 deletions src/routes/reputation.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { ReputationController } from '../controllers/reputation.controller';

const router = Router();

/**
* @title Reputation API Routes
* @dev NatSpec: Exposes REST API paths for the Freelancer Reputation Profile API.
*/

// GET /api/v1/reputation/:id - Retrieve reputation for a freelancer
router.get('/:id', ReputationController.getProfile);

// PUT /api/v1/reputation/:id - Update reputation for a freelancer (add review)
router.put('/:id', ReputationController.updateProfile);

export default router;
Loading
Loading