diff --git a/src/app.module.ts b/src/app.module.ts index e1e6a11..16d26ec 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.mod import { OffersModule } from './modules/offers/offers.module'; import { SupabaseModule } from './modules/supabase/supabase.module'; import { StoresModule } from './modules/stores/stores.module'; +import { ReviewsModule } from './modules/reviews/reviews.module'; import { HealthModule } from './health/health.module'; // Entities @@ -39,6 +40,8 @@ import { BuyerRequest } from './modules/buyer-requests/entities/buyer-request.en import { Offer } from './modules/offers/entities/offer.entity'; import { OfferAttachment } from './modules/offers/entities/offer-attachment.entity'; import { Store } from './modules/stores/entities/store.entity'; +import { Review } from './modules/reviews/entities/review.entity'; +import { SellerReview } from './modules/reviews/entities/seller-review.entity'; @Module({ imports: [ @@ -67,6 +70,8 @@ import { Store } from './modules/stores/entities/store.entity'; Offer, OfferAttachment, Store, + Review, + SellerReview, ], synchronize: false, logging: process.env.NODE_ENV === 'development', @@ -86,6 +91,7 @@ import { Store } from './modules/stores/entities/store.entity'; OffersModule, SupabaseModule, StoresModule, + ReviewsModule, HealthModule, ], }) diff --git a/src/migrations/1756000000000-CreateSellerReviewsTable.ts b/src/migrations/1756000000000-CreateSellerReviewsTable.ts new file mode 100644 index 0000000..6462d92 --- /dev/null +++ b/src/migrations/1756000000000-CreateSellerReviewsTable.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner, Table, Index, Check } from 'typeorm'; + +export class CreateSellerReviewsTable1756000000000 implements MigrationInterface { + name = 'CreateSellerReviewsTable1756000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create seller_reviews table + await queryRunner.createTable( + new Table({ + name: 'seller_reviews', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'offer_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'buyer_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'rating', + type: 'int', + isNullable: false, + }, + { + name: 'comment', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + foreignKeys: [ + { + columnNames: ['offer_id'], + referencedTableName: 'offers', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + columnNames: ['buyer_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + indices: [ + new Index('IDX_seller_reviews_offer_id', ['offer_id']), + new Index('IDX_seller_reviews_buyer_id', ['buyer_id']), + new Index('IDX_seller_reviews_created_at', ['created_at']), + ], + checks: [ + new Check('CHK_seller_reviews_rating_range', '"rating" >= 1 AND "rating" <= 5'), + ], + uniques: [ + { + name: 'UQ_seller_reviews_offer_buyer', + columnNames: ['offer_id', 'buyer_id'], + }, + ], + }), + true + ); + + // Add seller rating columns to users table + await queryRunner.addColumn( + 'users', + { + name: 'average_seller_rating', + type: 'decimal', + precision: 3, + scale: 2, + isNullable: true, + } + ); + + await queryRunner.addColumn( + 'users', + { + name: 'total_seller_reviews', + type: 'int', + default: 0, + isNullable: false, + } + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove seller rating columns from users table + await queryRunner.dropColumn('users', 'total_seller_reviews'); + await queryRunner.dropColumn('users', 'average_seller_rating'); + + // Drop seller_reviews table + await queryRunner.dropTable('seller_reviews'); + } +} diff --git a/src/modules/reviews/README.md b/src/modules/reviews/README.md new file mode 100644 index 0000000..d36ea83 --- /dev/null +++ b/src/modules/reviews/README.md @@ -0,0 +1,236 @@ +# Seller Review System + +This module implements a comprehensive review and rating system for sellers based on completed offers. Buyers can rate and review sellers after confirming a purchase, which helps build trust and assists future buyers in making informed decisions. + +## Features + +- ✅ **One review per offer**: Ensures each offer can only be reviewed once +- ✅ **Buyer validation**: Only the buyer who confirmed the purchase can review +- ✅ **Rating validation**: Ratings must be between 1-5 stars +- ✅ **Seller rating aggregation**: Automatic calculation of average ratings +- ✅ **Comprehensive API**: Full CRUD operations for reviews +- ✅ **Data validation**: Robust input validation and error handling +- ✅ **Test coverage**: Unit and integration tests included + +## Database Schema + +### SellerReviews Table +```sql +CREATE TABLE seller_reviews ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + offer_id UUID NOT NULL REFERENCES offers(id) ON DELETE CASCADE, + buyer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INT NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(offer_id, buyer_id) +); +``` + +### User Table Updates +```sql +ALTER TABLE users ADD COLUMN average_seller_rating DECIMAL(3,2); +ALTER TABLE users ADD COLUMN total_seller_reviews INT DEFAULT 0; +``` + +## API Endpoints + +### POST /api/v1/reviews +Create a new review for a seller based on an offer. + +**Authentication**: Required (JWT) + +**Request Body**: +```json +{ + "offerId": "uuid", + "rating": 5, + "comment": "Great seller! Very responsive and professional." +} +``` + +**Response**: +```json +{ + "success": true, + "data": { + "id": "review-uuid", + "offerId": "offer-uuid", + "buyerId": "buyer-uuid", + "rating": 5, + "comment": "Great seller!", + "createdAt": "2024-01-15T10:30:00Z", + "buyer": { + "id": "buyer-uuid", + "name": "John Doe", + "walletAddress": "buyer-wallet-address" + }, + "offer": { + "id": "offer-uuid", + "title": "Custom Product", + "price": 100.00, + "sellerId": "seller-uuid" + } + } +} +``` + +### GET /api/v1/users/:id/reviews +Get all reviews for a specific seller. + +**Authentication**: Not required (Public endpoint) + +**Response**: +```json +{ + "success": true, + "data": { + "reviews": [ + { + "id": "review-uuid", + "offerId": "offer-uuid", + "buyerId": "buyer-uuid", + "rating": 5, + "comment": "Great seller!", + "createdAt": "2024-01-15T10:30:00Z", + "buyer": { + "id": "buyer-uuid", + "name": "John Doe", + "walletAddress": "buyer-wallet-address" + }, + "offer": { + "id": "offer-uuid", + "title": "Custom Product", + "price": 100.00, + "sellerId": "seller-uuid" + } + } + ], + "averageRating": 4.5, + "totalReviews": 10, + "seller": { + "id": "seller-uuid", + "name": "Jane Smith", + "walletAddress": "seller-wallet-address", + "averageSellerRating": 4.5, + "totalSellerReviews": 10 + } + } +} +``` + +### PUT /api/v1/reviews/:id +Update an existing review. + +**Authentication**: Required (JWT) + +**Request Body**: +```json +{ + "rating": 4, + "comment": "Updated comment" +} +``` + +### DELETE /api/v1/reviews/:id +Delete a review. + +**Authentication**: Required (JWT) + +## Business Rules + +1. **One Review Per Offer**: Each offer can only be reviewed once by the buyer +2. **Buyer Validation**: Only the buyer who confirmed the purchase can review the offer +3. **Purchase Confirmation**: Reviews can only be created for offers that have been purchased (`wasPurchased = true`) +4. **Rating Range**: Ratings must be between 1 and 5 stars +5. **Ownership**: Users can only update/delete their own reviews +6. **Automatic Aggregation**: Seller ratings are automatically calculated and updated + +## Validation Rules + +- `offerId`: Must be a valid UUID and reference an existing offer +- `rating`: Must be an integer between 1 and 5 +- `comment`: Optional text field (max length handled by database) +- `buyerId`: Automatically set from authenticated user + +## Error Handling + +The system provides comprehensive error handling for various scenarios: + +- **404 Not Found**: When offer, user, or review doesn't exist +- **400 Bad Request**: Invalid input data, rating out of range, offer not purchased +- **403 Forbidden**: User trying to review offer they didn't purchase +- **409 Conflict**: Attempting to create duplicate review + +## Testing + +The module includes comprehensive test coverage: + +- **Unit Tests**: Service and controller logic +- **Integration Tests**: End-to-end API testing +- **Validation Tests**: Input validation and error handling + +Run tests with: +```bash +npm test -- --testPathPattern=seller-review +``` + +## Usage Examples + +### Creating a Review +```typescript +const reviewData = { + offerId: 'offer-uuid', + rating: 5, + comment: 'Excellent service and fast delivery!' +}; + +const review = await sellerReviewService.createReview(buyerId, reviewData); +``` + +### Getting Seller Reviews +```typescript +const sellerReviews = await sellerReviewService.getSellerReviews(sellerId); +console.log(`Average rating: ${sellerReviews.averageRating}`); +console.log(`Total reviews: ${sellerReviews.totalReviews}`); +``` + +### Updating a Review +```typescript +const updateData = { + rating: 4, + comment: 'Updated my rating after further consideration' +}; + +const updatedReview = await sellerReviewService.updateReview( + reviewId, + buyerId, + updateData +); +``` + +## Database Migration + +To apply the database changes, run the migration: + +```bash +npm run migration:run +``` + +The migration will: +1. Create the `seller_reviews` table with proper constraints +2. Add seller rating columns to the `users` table +3. Create necessary indexes for performance + +## Performance Considerations + +- Indexes are created on frequently queried columns (`offer_id`, `buyer_id`, `created_at`) +- Seller rating aggregation is cached in the `users` table +- Queries use proper joins to minimize database round trips + +## Security + +- JWT authentication required for creating, updating, and deleting reviews +- Input validation prevents malicious data +- Foreign key constraints ensure data integrity +- Users can only modify their own reviews diff --git a/src/modules/reviews/controllers/seller-review.controller.ts b/src/modules/reviews/controllers/seller-review.controller.ts new file mode 100644 index 0000000..e6cc6eb --- /dev/null +++ b/src/modules/reviews/controllers/seller-review.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + Request, + BadRequestException, +} from '@nestjs/common'; +import { SellerReviewService } from '../services/seller-review.service'; +import { CreateSellerReviewDTO, UpdateSellerReviewDTO } from '../dto/seller-review.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { AuthRequest } from '../../wishlist/common/types/auth-request.type'; + +@Controller('reviews') +export class SellerReviewController { + constructor(private readonly sellerReviewService: SellerReviewService) {} + + @Post() + @UseGuards(JwtAuthGuard) + async createReview(@Body() createReviewDto: CreateSellerReviewDTO, @Request() req: AuthRequest) { + const buyerId = req.user.id; + if (!buyerId) { + throw new BadRequestException('User ID is required'); + } + + return this.sellerReviewService.createReview(buyerId, createReviewDto); + } + + @Get('users/:id/reviews') + async getSellerReviews(@Param('id') sellerId: string) { + if (!sellerId) { + throw new BadRequestException('Seller ID is required'); + } + + return this.sellerReviewService.getSellerReviews(sellerId); + } + + @Put(':id') + @UseGuards(JwtAuthGuard) + async updateReview( + @Param('id') reviewId: string, + @Body() updateData: UpdateSellerReviewDTO, + @Request() req: AuthRequest + ) { + const buyerId = req.user.id; + if (!buyerId) { + throw new BadRequestException('User ID is required'); + } + + if (!reviewId) { + throw new BadRequestException('Review ID is required'); + } + + return this.sellerReviewService.updateReview(reviewId, buyerId, updateData); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard) + async deleteReview(@Param('id') reviewId: string, @Request() req: AuthRequest) { + const buyerId = req.user.id; + if (!buyerId) { + throw new BadRequestException('User ID is required'); + } + + if (!reviewId) { + throw new BadRequestException('Review ID is required'); + } + + return this.sellerReviewService.deleteReview(reviewId, buyerId); + } +} diff --git a/src/modules/reviews/dto/seller-review.dto.ts b/src/modules/reviews/dto/seller-review.dto.ts new file mode 100644 index 0000000..e692044 --- /dev/null +++ b/src/modules/reviews/dto/seller-review.dto.ts @@ -0,0 +1,61 @@ +import { IsString, IsNumber, IsOptional, Min, Max, IsNotEmpty, IsUUID } from 'class-validator'; + +export class CreateSellerReviewDTO { + @IsUUID() + @IsNotEmpty() + offerId: string; + + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @IsString() + @IsOptional() + comment?: string; +} + +export class SellerReviewResponseDTO { + id: string; + offerId: string; + buyerId: string; + rating: number; + comment?: string; + createdAt: Date; + buyer?: { + id: string; + name?: string; + walletAddress: string; + }; + offer?: { + id: string; + title: string; + price: number; + sellerId: string; + }; +} + +export class SellerReviewsResponseDTO { + reviews: SellerReviewResponseDTO[]; + averageRating: number; + totalReviews: number; + seller: { + id: string; + name?: string; + walletAddress: string; + averageSellerRating: number; + totalSellerReviews: number; + }; +} + +export class UpdateSellerReviewDTO { + @IsNumber() + @Min(1) + @Max(5) + @IsOptional() + rating?: number; + + @IsString() + @IsOptional() + comment?: string; +} diff --git a/src/modules/reviews/entities/seller-review.entity.ts b/src/modules/reviews/entities/seller-review.entity.ts new file mode 100644 index 0000000..f069ed0 --- /dev/null +++ b/src/modules/reviews/entities/seller-review.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + Unique, + Check, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Offer } from '../../offers/entities/offer.entity'; + +@Entity('seller_reviews') +@Unique(['offerId', 'buyerId']) // Ensure one review per offer per buyer +@Check(`"rating" >= 1 AND "rating" <= 5`) // Ensure rating is between 1-5 +export class SellerReview { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'offer_id', type: 'uuid' }) + offerId: string; + + @ManyToOne(() => Offer, { nullable: false }) + @JoinColumn({ name: 'offer_id' }) + offer: Offer; + + @Column({ name: 'buyer_id', type: 'uuid' }) + buyerId: string; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'buyer_id' }) + buyer: User; + + @Column({ type: 'int' }) + rating: number; + + @Column({ type: 'text', nullable: true }) + comment: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/src/modules/reviews/middlewares/seller-review.middleware.ts b/src/modules/reviews/middlewares/seller-review.middleware.ts new file mode 100644 index 0000000..1898e65 --- /dev/null +++ b/src/modules/reviews/middlewares/seller-review.middleware.ts @@ -0,0 +1,70 @@ +import { Response, NextFunction } from 'express'; +import { validate } from 'class-validator'; +import { plainToClass } from 'class-transformer'; +import { CreateSellerReviewDTO, UpdateSellerReviewDTO } from '../dto/seller-review.dto'; +import { BadRequestError } from '../../../utils/errors'; +import { AuthenticatedRequest } from '../../shared/types/auth-request.type'; + +export const validateSellerReviewData = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const dto = plainToClass(CreateSellerReviewDTO, req.body); + const errors = await validate(dto); + + if (errors.length > 0) { + const errorMessages = errors.map(error => + Object.values(error.constraints || {}).join(', ') + ).join('; '); + + throw new BadRequestError(`Validation failed: ${errorMessages}`); + } + + // Additional validation for rating range + if (dto.rating < 1 || dto.rating > 5) { + throw new BadRequestError('Rating must be between 1 and 5'); + } + + // Validate offerId is a valid UUID + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(dto.offerId)) { + throw new BadRequestError('Invalid offer ID format'); + } + + req.body = dto; + next(); + } catch (error) { + next(error); + } +}; + +export const validateUpdateSellerReviewData = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const dto = plainToClass(UpdateSellerReviewDTO, req.body); + const errors = await validate(dto); + + if (errors.length > 0) { + const errorMessages = errors.map(error => + Object.values(error.constraints || {}).join(', ') + ).join('; '); + + throw new BadRequestError(`Validation failed: ${errorMessages}`); + } + + // Additional validation for rating range if provided + if (dto.rating !== undefined && (dto.rating < 1 || dto.rating > 5)) { + throw new BadRequestError('Rating must be between 1 and 5'); + } + + req.body = dto; + next(); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/reviews/reviews.module.ts b/src/modules/reviews/reviews.module.ts index 6f97dcd..527fe47 100644 --- a/src/modules/reviews/reviews.module.ts +++ b/src/modules/reviews/reviews.module.ts @@ -1,14 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Review } from './entities/review.entity'; +import { SellerReview } from './entities/seller-review.entity'; import { ReviewService } from './services/review.service'; +import { SellerReviewService } from './services/seller-review.service'; import { ReviewController } from './controllers/review.controller'; +import { SellerReviewController } from './controllers/seller-review.controller'; import { SharedModule } from '../shared/shared.module'; @Module({ - imports: [TypeOrmModule.forFeature([Review]), SharedModule], - controllers: [ReviewController], - providers: [ReviewService], - exports: [ReviewService], + imports: [TypeOrmModule.forFeature([Review, SellerReview]), SharedModule], + controllers: [ReviewController, SellerReviewController], + providers: [ReviewService, SellerReviewService], + exports: [ReviewService, SellerReviewService], }) export class ReviewsModule {} diff --git a/src/modules/reviews/routes/seller-review.routes.ts b/src/modules/reviews/routes/seller-review.routes.ts new file mode 100644 index 0000000..32bd162 --- /dev/null +++ b/src/modules/reviews/routes/seller-review.routes.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { SellerReviewController } from '../controllers/seller-review.controller'; +import { validateSellerReviewData } from '../middlewares/seller-review.middleware'; +import { jwtAuthMiddleware } from '../../auth/middleware/jwt-auth.middleware'; +import { AuthenticatedRequest } from '../../shared/types/auth-request.type'; + +const router = Router(); +const sellerReviewController = new SellerReviewController(); + +/** + * @route POST /api/v1/reviews + * @desc Create a new review for a seller based on an offer + * @access Private (Authenticated Users Only) + */ +router.post( + '/', + jwtAuthMiddleware, + (req, res, next) => validateSellerReviewData(req as AuthenticatedRequest, res, next), + (req, res, next) => sellerReviewController.createReview(req as AuthenticatedRequest, res, next) +); + +/** + * @route GET /api/v1/users/:id/reviews + * @desc Get all reviews for a specific seller + * @access Public + */ +router.get('/users/:id/reviews', (req, res, next) => + sellerReviewController.getSellerReviews(req as AuthenticatedRequest, res, next) +); + +/** + * @route PUT /api/v1/reviews/:id + * @desc Update a review (only by the user who created it) + * @access Private (Authenticated Users Only) + */ +router.put( + '/:id', + jwtAuthMiddleware, + (req, res, next) => sellerReviewController.updateReview(req as AuthenticatedRequest, res, next) +); + +/** + * @route DELETE /api/v1/reviews/:id + * @desc Delete a review (only by the user who created it) + * @access Private (Authenticated Users Only) + */ +router.delete( + '/:id', + jwtAuthMiddleware, + (req, res, next) => sellerReviewController.deleteReview(req as AuthenticatedRequest, res, next) +); + +export default router; diff --git a/src/modules/reviews/services/seller-review.service.ts b/src/modules/reviews/services/seller-review.service.ts new file mode 100644 index 0000000..1bb7146 --- /dev/null +++ b/src/modules/reviews/services/seller-review.service.ts @@ -0,0 +1,248 @@ +import { Repository } from 'typeorm'; +import { SellerReview } from '../entities/seller-review.entity'; +import { User } from '../../users/entities/user.entity'; +import { Offer } from '../../offers/entities/offer.entity'; +import AppDataSource from '../../../config/ormconfig'; +import { NotFoundError, BadRequestError, ForbiddenError } from '../../../utils/errors'; +import { + SellerReviewResponseDTO, + SellerReviewsResponseDTO, + CreateSellerReviewDTO +} from '../dto/seller-review.dto'; + +export class SellerReviewService { + private reviewRepository: Repository; + private userRepository: Repository; + private offerRepository: Repository; + + constructor() { + this.reviewRepository = AppDataSource.getRepository(SellerReview); + this.userRepository = AppDataSource.getRepository(User); + this.offerRepository = AppDataSource.getRepository(Offer); + } + + async createReview( + buyerId: string, + createReviewDto: CreateSellerReviewDTO + ): Promise { + const { offerId, rating, comment } = createReviewDto; + + // Validate offer exists and get it with relations + const offer = await this.offerRepository.findOne({ + where: { id: offerId }, + relations: ['seller', 'buyerRequest'], + }); + + if (!offer) { + throw new NotFoundError(`Offer with ID ${offerId} not found`); + } + + // Validate that the buyer is the one who made the purchase + if (offer.buyerRequest.buyerId !== buyerId) { + throw new ForbiddenError('Only the buyer who confirmed the purchase can review this offer'); + } + + // Validate that the offer was purchased + if (!offer.wasPurchased) { + throw new BadRequestError('Can only review offers that have been purchased'); + } + + // Check if review already exists + const existingReview = await this.reviewRepository.findOne({ + where: { offerId, buyerId }, + }); + + if (existingReview) { + throw new BadRequestError('You have already reviewed this offer'); + } + + // Validate buyer exists + const buyer = await this.userRepository.findOne({ + where: { id: buyerId }, + }); + + if (!buyer) { + throw new NotFoundError(`Buyer with ID ${buyerId} not found`); + } + + // Create the review + const review = this.reviewRepository.create({ + offerId, + buyerId, + rating, + comment, + }); + + try { + const savedReview = await this.reviewRepository.save(review); + + // Update seller's average rating + await this.updateSellerRating(offer.sellerId); + + return this.mapToResponseDTO(savedReview, buyer, offer); + } catch (error) { + throw new BadRequestError(`Failed to create review: ${error.message}`); + } + } + + async getSellerReviews(sellerId: string): Promise { + // Validate seller exists + const seller = await this.userRepository.findOne({ + where: { id: sellerId }, + }); + + if (!seller) { + throw new NotFoundError(`Seller with ID ${sellerId} not found`); + } + + // Get all reviews for this seller + const reviews = await this.reviewRepository.find({ + where: { offer: { sellerId } }, + relations: ['buyer', 'offer'], + order: { createdAt: 'DESC' }, + }); + + // Calculate average rating + const totalRating = reviews.reduce((sum, review) => sum + review.rating, 0); + const averageRating = reviews.length > 0 ? totalRating / reviews.length : 0; + + // Map to response DTOs + const reviewDTOs: SellerReviewResponseDTO[] = reviews.map((review) => + this.mapToResponseDTO(review, review.buyer, review.offer) + ); + + return { + reviews: reviewDTOs, + averageRating: Math.round(averageRating * 100) / 100, // Round to 2 decimal places + totalReviews: reviews.length, + seller: { + id: seller.id, + name: seller.name, + walletAddress: seller.walletAddress, + averageSellerRating: seller.averageSellerRating || 0, + totalSellerReviews: seller.totalSellerReviews || 0, + }, + }; + } + + async updateReview( + reviewId: string, + buyerId: string, + updateData: { rating?: number; comment?: string } + ): Promise { + const review = await this.reviewRepository.findOne({ + where: { id: reviewId }, + relations: ['buyer', 'offer'], + }); + + if (!review) { + throw new NotFoundError(`Review with ID ${reviewId} not found`); + } + + if (review.buyerId !== buyerId) { + throw new ForbiddenError('You can only update your own reviews'); + } + + // Update the review + if (updateData.rating !== undefined) { + review.rating = updateData.rating; + } + if (updateData.comment !== undefined) { + review.comment = updateData.comment; + } + + try { + const updatedReview = await this.reviewRepository.save(review); + + // Update seller's average rating + await this.updateSellerRating(review.offer.sellerId); + + return this.mapToResponseDTO(updatedReview, review.buyer, review.offer); + } catch (error) { + throw new BadRequestError(`Failed to update review: ${error.message}`); + } + } + + async deleteReview(reviewId: string, buyerId: string): Promise { + const review = await this.reviewRepository.findOne({ + where: { id: reviewId }, + relations: ['offer'], + }); + + if (!review) { + throw new NotFoundError(`Review with ID ${reviewId} not found`); + } + + if (review.buyerId !== buyerId) { + throw new ForbiddenError('You can only delete your own reviews'); + } + + try { + const result = await this.reviewRepository.delete(reviewId); + + if (result.affected === 1) { + // Update seller's average rating + await this.updateSellerRating(review.offer.sellerId); + return true; + } + return false; + } catch (error) { + throw new BadRequestError(`Failed to delete review: ${error.message}`); + } + } + + async getSellerAverageRating(sellerId: string): Promise { + const result = await this.reviewRepository + .createQueryBuilder('review') + .leftJoin('review.offer', 'offer') + .select('AVG(review.rating)', 'averageRating') + .where('offer.sellerId = :sellerId', { sellerId }) + .getRawOne(); + + return result.averageRating ? parseFloat(result.averageRating) : 0; + } + + private async updateSellerRating(sellerId: string): Promise { + const result = await this.reviewRepository + .createQueryBuilder('review') + .leftJoin('review.offer', 'offer') + .select('AVG(review.rating)', 'averageRating') + .addSelect('COUNT(review.id)', 'totalReviews') + .where('offer.sellerId = :sellerId', { sellerId }) + .getRawOne(); + + const averageRating = result.averageRating ? parseFloat(result.averageRating) : 0; + const totalReviews = parseInt(result.totalReviews) || 0; + + await this.userRepository.update(sellerId, { + averageSellerRating: Math.round(averageRating * 100) / 100, + totalSellerReviews: totalReviews, + }); + } + + private mapToResponseDTO( + review: SellerReview, + buyer: User, + offer: Offer + ): SellerReviewResponseDTO { + return { + id: review.id, + offerId: review.offerId, + buyerId: review.buyerId, + rating: review.rating, + comment: review.comment, + createdAt: review.createdAt, + buyer: { + id: buyer.id, + name: buyer.name, + walletAddress: buyer.walletAddress, + }, + offer: { + id: offer.id, + title: offer.title, + price: offer.price, + sellerId: offer.sellerId, + }, + }; + } +} diff --git a/src/modules/reviews/tests/seller-review.controller.spec.ts b/src/modules/reviews/tests/seller-review.controller.spec.ts new file mode 100644 index 0000000..d188c85 --- /dev/null +++ b/src/modules/reviews/tests/seller-review.controller.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SellerReviewController } from '../controllers/seller-review.controller'; +import { SellerReviewService } from '../services/seller-review.service'; +import { CreateSellerReviewDTO, UpdateSellerReviewDTO } from '../dto/seller-review.dto'; +import { BadRequestException } from '@nestjs/common'; + +describe('SellerReviewController', () => { + let controller: SellerReviewController; + let service: SellerReviewService; + + const mockSellerReviewService = { + createReview: jest.fn(), + getSellerReviews: jest.fn(), + updateReview: jest.fn(), + deleteReview: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SellerReviewController], + providers: [ + { + provide: SellerReviewService, + useValue: mockSellerReviewService, + }, + ], + }).compile(); + + controller = module.get(SellerReviewController); + service = module.get(SellerReviewService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createReview', () => { + const mockCreateReviewDto: CreateSellerReviewDTO = { + offerId: 'offer-uuid', + rating: 5, + comment: 'Great seller!', + }; + + const mockUser = { id: 'buyer-uuid' }; + const mockRequest = { user: mockUser }; + + const mockReview = { + id: 'review-uuid', + offerId: 'offer-uuid', + buyerId: 'buyer-uuid', + rating: 5, + comment: 'Great seller!', + createdAt: new Date(), + }; + + it('should create a review successfully', async () => { + mockSellerReviewService.createReview.mockResolvedValue(mockReview); + + const result = await controller.createReview(mockCreateReviewDto, mockRequest as any); + + expect(service.createReview).toHaveBeenCalledWith('buyer-uuid', mockCreateReviewDto); + expect(result).toEqual(mockReview); + }); + + it('should throw BadRequestException when user ID is missing', async () => { + const mockRequestWithoutUser = { user: null }; + + await expect(controller.createReview(mockCreateReviewDto, mockRequestWithoutUser as any)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('getSellerReviews', () => { + const mockSellerId = 'seller-uuid'; + const mockReviewsData = { + reviews: [ + { + id: 'review-uuid', + offerId: 'offer-uuid', + buyerId: 'buyer-uuid', + rating: 5, + comment: 'Great seller!', + createdAt: new Date(), + }, + ], + averageRating: 5, + totalReviews: 1, + seller: { + id: 'seller-uuid', + name: 'Test Seller', + walletAddress: 'seller-wallet', + averageSellerRating: 5, + totalSellerReviews: 1, + }, + }; + + it('should return seller reviews successfully', async () => { + mockSellerReviewService.getSellerReviews.mockResolvedValue(mockReviewsData); + + const result = await controller.getSellerReviews(mockSellerId); + + expect(service.getSellerReviews).toHaveBeenCalledWith(mockSellerId); + expect(result).toEqual(mockReviewsData); + }); + + it('should throw BadRequestException when seller ID is missing', async () => { + await expect(controller.getSellerReviews('')) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('updateReview', () => { + const mockReviewId = 'review-uuid'; + const mockUpdateData: UpdateSellerReviewDTO = { + rating: 4, + comment: 'Updated comment', + }; + + const mockUser = { id: 'buyer-uuid' }; + const mockRequest = { user: mockUser }; + + const mockUpdatedReview = { + id: 'review-uuid', + offerId: 'offer-uuid', + buyerId: 'buyer-uuid', + rating: 4, + comment: 'Updated comment', + createdAt: new Date(), + }; + + it('should update review successfully', async () => { + mockSellerReviewService.updateReview.mockResolvedValue(mockUpdatedReview); + + const result = await controller.updateReview(mockReviewId, mockUpdateData, mockRequest as any); + + expect(service.updateReview).toHaveBeenCalledWith(mockReviewId, 'buyer-uuid', mockUpdateData); + expect(result).toEqual(mockUpdatedReview); + }); + + it('should throw BadRequestException when user ID is missing', async () => { + const mockRequestWithoutUser = { user: null }; + + await expect(controller.updateReview(mockReviewId, mockUpdateData, mockRequestWithoutUser as any)) + .rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when review ID is missing', async () => { + await expect(controller.updateReview('', mockUpdateData, mockRequest as any)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('deleteReview', () => { + const mockReviewId = 'review-uuid'; + const mockUser = { id: 'buyer-uuid' }; + const mockRequest = { user: mockUser }; + + it('should delete review successfully', async () => { + mockSellerReviewService.deleteReview.mockResolvedValue(true); + + const result = await controller.deleteReview(mockReviewId, mockRequest as any); + + expect(service.deleteReview).toHaveBeenCalledWith(mockReviewId, 'buyer-uuid'); + expect(result).toBe(true); + }); + + it('should throw BadRequestException when user ID is missing', async () => { + const mockRequestWithoutUser = { user: null }; + + await expect(controller.deleteReview(mockReviewId, mockRequestWithoutUser as any)) + .rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when review ID is missing', async () => { + await expect(controller.deleteReview('', mockRequest as any)) + .rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/modules/reviews/tests/seller-review.integration.spec.ts b/src/modules/reviews/tests/seller-review.integration.spec.ts new file mode 100644 index 0000000..e909b44 --- /dev/null +++ b/src/modules/reviews/tests/seller-review.integration.spec.ts @@ -0,0 +1,327 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as request from 'supertest'; +import { SellerReviewService } from '../services/seller-review.service'; +import { SellerReviewController } from '../controllers/seller-review.controller'; +import { SellerReview } from '../entities/seller-review.entity'; +import { User } from '../../users/entities/user.entity'; +import { Offer } from '../../offers/entities/offer.entity'; +import { BuyerRequest } from '../../buyer-requests/entities/buyer-request.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CreateSellerReviewDTO } from '../dto/seller-review.dto'; + +describe('SellerReview Integration Tests', () => { + let app: INestApplication; + let reviewRepository: Repository; + let userRepository: Repository; + let offerRepository: Repository; + let buyerRequestRepository: Repository; + + const mockJwtAuthGuard = { + canActivate: jest.fn(() => true), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + entities: [SellerReview, User, Offer, BuyerRequest], + synchronize: true, + }), + TypeOrmModule.forFeature([SellerReview, User, Offer, BuyerRequest]), + ], + controllers: [SellerReviewController], + providers: [SellerReviewService], + }) + .overrideGuard(JwtAuthGuard) + .useValue(mockJwtAuthGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + reviewRepository = moduleFixture.get>(getRepositoryToken(SellerReview)); + userRepository = moduleFixture.get>(getRepositoryToken(User)); + offerRepository = moduleFixture.get>(getRepositoryToken(Offer)); + buyerRequestRepository = moduleFixture.get>(getRepositoryToken(BuyerRequest)); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Clear all tables before each test + await reviewRepository.clear(); + await offerRepository.clear(); + await buyerRequestRepository.clear(); + await userRepository.clear(); + + // Mock user for authentication + mockJwtAuthGuard.canActivate.mockImplementation((context) => { + const request = context.switchToHttp().getRequest(); + request.user = { id: 'buyer-uuid' }; + return true; + }); + }); + + describe('POST /reviews', () => { + it('should create a review successfully', async () => { + // Setup test data + const seller = await userRepository.save({ + id: 'seller-uuid', + walletAddress: 'seller-wallet', + name: 'Test Seller', + }); + + const buyer = await userRepository.save({ + id: 'buyer-uuid', + walletAddress: 'buyer-wallet', + name: 'Test Buyer', + }); + + const buyerRequest = await buyerRequestRepository.save({ + id: 1, + buyerId: 'buyer-uuid', + title: 'Test Request', + description: 'Test Description', + maxPrice: 100, + expiresAt: new Date(Date.now() + 86400000), // 24 hours from now + }); + + const offer = await offerRepository.save({ + id: 'offer-uuid', + buyerRequestId: 1, + sellerId: 'seller-uuid', + title: 'Test Offer', + description: 'Test Offer Description', + price: 50, + wasPurchased: true, + expiresAt: new Date(Date.now() + 86400000), + status: 'ACCEPTED' as any, + }); + + const createReviewDto: CreateSellerReviewDTO = { + offerId: 'offer-uuid', + rating: 5, + comment: 'Great seller!', + }; + + const response = await request(app.getHttpServer()) + .post('/reviews') + .send(createReviewDto) + .expect(201); + + expect(response.body).toHaveProperty('id'); + expect(response.body.rating).toBe(5); + expect(response.body.comment).toBe('Great seller!'); + expect(response.body.offerId).toBe('offer-uuid'); + expect(response.body.buyerId).toBe('buyer-uuid'); + }); + + it('should return 400 when offer does not exist', async () => { + const createReviewDto: CreateSellerReviewDTO = { + offerId: 'non-existent-offer', + rating: 5, + comment: 'Great seller!', + }; + + await request(app.getHttpServer()) + .post('/reviews') + .send(createReviewDto) + .expect(400); + }); + + it('should return 400 when rating is out of range', async () => { + const createReviewDto: CreateSellerReviewDTO = { + offerId: 'offer-uuid', + rating: 6, + comment: 'Great seller!', + }; + + await request(app.getHttpServer()) + .post('/reviews') + .send(createReviewDto) + .expect(400); + }); + }); + + describe('GET /users/:id/reviews', () => { + it('should return seller reviews successfully', async () => { + // Setup test data + const seller = await userRepository.save({ + id: 'seller-uuid', + walletAddress: 'seller-wallet', + name: 'Test Seller', + averageSellerRating: 4.5, + totalSellerReviews: 2, + }); + + const buyer = await userRepository.save({ + id: 'buyer-uuid', + walletAddress: 'buyer-wallet', + name: 'Test Buyer', + }); + + const buyerRequest = await buyerRequestRepository.save({ + id: 1, + buyerId: 'buyer-uuid', + title: 'Test Request', + description: 'Test Description', + maxPrice: 100, + expiresAt: new Date(Date.now() + 86400000), + }); + + const offer = await offerRepository.save({ + id: 'offer-uuid', + buyerRequestId: 1, + sellerId: 'seller-uuid', + title: 'Test Offer', + description: 'Test Offer Description', + price: 50, + wasPurchased: true, + expiresAt: new Date(Date.now() + 86400000), + status: 'ACCEPTED' as any, + }); + + const review = await reviewRepository.save({ + offerId: 'offer-uuid', + buyerId: 'buyer-uuid', + rating: 5, + comment: 'Great seller!', + }); + + const response = await request(app.getHttpServer()) + .get('/users/seller-uuid/reviews') + .expect(200); + + expect(response.body).toHaveProperty('reviews'); + expect(response.body).toHaveProperty('averageRating'); + expect(response.body).toHaveProperty('totalReviews'); + expect(response.body).toHaveProperty('seller'); + expect(response.body.seller.id).toBe('seller-uuid'); + expect(response.body.reviews).toHaveLength(1); + }); + + it('should return 404 when seller does not exist', async () => { + await request(app.getHttpServer()) + .get('/users/non-existent-seller/reviews') + .expect(404); + }); + }); + + describe('PUT /reviews/:id', () => { + it('should update review successfully', async () => { + // Setup test data + const seller = await userRepository.save({ + id: 'seller-uuid', + walletAddress: 'seller-wallet', + name: 'Test Seller', + }); + + const buyer = await userRepository.save({ + id: 'buyer-uuid', + walletAddress: 'buyer-wallet', + name: 'Test Buyer', + }); + + const buyerRequest = await buyerRequestRepository.save({ + id: 1, + buyerId: 'buyer-uuid', + title: 'Test Request', + description: 'Test Description', + maxPrice: 100, + expiresAt: new Date(Date.now() + 86400000), + }); + + const offer = await offerRepository.save({ + id: 'offer-uuid', + buyerRequestId: 1, + sellerId: 'seller-uuid', + title: 'Test Offer', + description: 'Test Offer Description', + price: 50, + wasPurchased: true, + expiresAt: new Date(Date.now() + 86400000), + status: 'ACCEPTED' as any, + }); + + const review = await reviewRepository.save({ + offerId: 'offer-uuid', + buyerId: 'buyer-uuid', + rating: 3, + comment: 'Original comment', + }); + + const updateData = { + rating: 5, + comment: 'Updated comment', + }; + + const response = await request(app.getHttpServer()) + .put(`/reviews/${review.id}`) + .send(updateData) + .expect(200); + + expect(response.body.rating).toBe(5); + expect(response.body.comment).toBe('Updated comment'); + }); + }); + + describe('DELETE /reviews/:id', () => { + it('should delete review successfully', async () => { + // Setup test data + const seller = await userRepository.save({ + id: 'seller-uuid', + walletAddress: 'seller-wallet', + name: 'Test Seller', + }); + + const buyer = await userRepository.save({ + id: 'buyer-uuid', + walletAddress: 'buyer-wallet', + name: 'Test Buyer', + }); + + const buyerRequest = await buyerRequestRepository.save({ + id: 1, + buyerId: 'buyer-uuid', + title: 'Test Request', + description: 'Test Description', + maxPrice: 100, + expiresAt: new Date(Date.now() + 86400000), + }); + + const offer = await offerRepository.save({ + id: 'offer-uuid', + buyerRequestId: 1, + sellerId: 'seller-uuid', + title: 'Test Offer', + description: 'Test Offer Description', + price: 50, + wasPurchased: true, + expiresAt: new Date(Date.now() + 86400000), + status: 'ACCEPTED' as any, + }); + + const review = await reviewRepository.save({ + offerId: 'offer-uuid', + buyerId: 'buyer-uuid', + rating: 5, + comment: 'Great seller!', + }); + + await request(app.getHttpServer()) + .delete(`/reviews/${review.id}`) + .expect(200); + + const deletedReview = await reviewRepository.findOne({ where: { id: review.id } }); + expect(deletedReview).toBeNull(); + }); + }); +}); diff --git a/src/modules/reviews/tests/seller-review.service.spec.ts b/src/modules/reviews/tests/seller-review.service.spec.ts new file mode 100644 index 0000000..6e90986 --- /dev/null +++ b/src/modules/reviews/tests/seller-review.service.spec.ts @@ -0,0 +1,289 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SellerReviewService } from '../services/seller-review.service'; +import { SellerReview } from '../entities/seller-review.entity'; +import { User } from '../../users/entities/user.entity'; +import { Offer } from '../../offers/entities/offer.entity'; +import { BuyerRequest } from '../../buyer-requests/entities/buyer-request.entity'; +import { NotFoundError, BadRequestError, ForbiddenError } from '../../../utils/errors'; + +describe('SellerReviewService', () => { + let service: SellerReviewService; + let reviewRepository: Repository; + let userRepository: Repository; + let offerRepository: Repository; + + const mockReviewRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + const mockOfferRepository = { + findOne: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SellerReviewService, + { + provide: getRepositoryToken(SellerReview), + useValue: mockReviewRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(Offer), + useValue: mockOfferRepository, + }, + ], + }).compile(); + + service = module.get(SellerReviewService); + reviewRepository = module.get>(getRepositoryToken(SellerReview)); + userRepository = module.get>(getRepositoryToken(User)); + offerRepository = module.get>(getRepositoryToken(Offer)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createReview', () => { + const mockBuyerId = 'buyer-uuid'; + const mockOfferId = 'offer-uuid'; + const mockSellerId = 'seller-uuid'; + const mockCreateReviewDto = { + offerId: mockOfferId, + rating: 5, + comment: 'Great seller!', + }; + + const mockOffer = { + id: mockOfferId, + sellerId: mockSellerId, + wasPurchased: true, + buyerRequest: { buyerId: mockBuyerId }, + seller: { id: mockSellerId }, + }; + + const mockBuyer = { + id: mockBuyerId, + name: 'Test Buyer', + walletAddress: 'test-wallet', + }; + + const mockReview = { + id: 'review-uuid', + offerId: mockOfferId, + buyerId: mockBuyerId, + rating: 5, + comment: 'Great seller!', + createdAt: new Date(), + }; + + it('should create a review successfully', async () => { + mockOfferRepository.findOne.mockResolvedValue(mockOffer); + mockUserRepository.findOne.mockResolvedValue(mockBuyer); + mockReviewRepository.findOne.mockResolvedValue(null); + mockReviewRepository.create.mockReturnValue(mockReview); + mockReviewRepository.save.mockResolvedValue(mockReview); + mockUserRepository.update.mockResolvedValue({}); + + const result = await service.createReview(mockBuyerId, mockCreateReviewDto); + + expect(mockOfferRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockOfferId }, + relations: ['seller', 'buyerRequest'], + }); + expect(mockUserRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockBuyerId }, + }); + expect(mockReviewRepository.findOne).toHaveBeenCalledWith({ + where: { offerId: mockOfferId, buyerId: mockBuyerId }, + }); + expect(mockReviewRepository.create).toHaveBeenCalledWith({ + offerId: mockOfferId, + buyerId: mockBuyerId, + rating: 5, + comment: 'Great seller!', + }); + expect(result).toBeDefined(); + }); + + it('should throw NotFoundError when offer does not exist', async () => { + mockOfferRepository.findOne.mockResolvedValue(null); + + await expect(service.createReview(mockBuyerId, mockCreateReviewDto)) + .rejects.toThrow(NotFoundError); + }); + + it('should throw ForbiddenError when buyer is not the one who made the purchase', async () => { + const wrongBuyerId = 'wrong-buyer-uuid'; + mockOfferRepository.findOne.mockResolvedValue(mockOffer); + + await expect(service.createReview(wrongBuyerId, mockCreateReviewDto)) + .rejects.toThrow(ForbiddenError); + }); + + it('should throw BadRequestError when offer was not purchased', async () => { + const unpurchasedOffer = { ...mockOffer, wasPurchased: false }; + mockOfferRepository.findOne.mockResolvedValue(unpurchasedOffer); + + await expect(service.createReview(mockBuyerId, mockCreateReviewDto)) + .rejects.toThrow(BadRequestError); + }); + + it('should throw BadRequestError when review already exists', async () => { + mockOfferRepository.findOne.mockResolvedValue(mockOffer); + mockUserRepository.findOne.mockResolvedValue(mockBuyer); + mockReviewRepository.findOne.mockResolvedValue(mockReview); + + await expect(service.createReview(mockBuyerId, mockCreateReviewDto)) + .rejects.toThrow(BadRequestError); + }); + + it('should throw BadRequestError when rating is out of range', async () => { + const invalidDto = { ...mockCreateReviewDto, rating: 6 }; + mockOfferRepository.findOne.mockResolvedValue(mockOffer); + + await expect(service.createReview(mockBuyerId, invalidDto)) + .rejects.toThrow(BadRequestError); + }); + }); + + describe('getSellerReviews', () => { + const mockSellerId = 'seller-uuid'; + const mockSeller = { + id: mockSellerId, + name: 'Test Seller', + walletAddress: 'seller-wallet', + averageSellerRating: 4.5, + totalSellerReviews: 10, + }; + + const mockReviews = [ + { + id: 'review-1', + offerId: 'offer-1', + buyerId: 'buyer-1', + rating: 5, + comment: 'Great!', + createdAt: new Date(), + buyer: { id: 'buyer-1', name: 'Buyer 1', walletAddress: 'wallet-1' }, + offer: { id: 'offer-1', title: 'Test Offer', price: 100, sellerId: mockSellerId }, + }, + ]; + + it('should return seller reviews successfully', async () => { + mockUserRepository.findOne.mockResolvedValue(mockSeller); + mockReviewRepository.find.mockResolvedValue(mockReviews); + + const result = await service.getSellerReviews(mockSellerId); + + expect(result).toHaveProperty('reviews'); + expect(result).toHaveProperty('averageRating'); + expect(result).toHaveProperty('totalReviews'); + expect(result).toHaveProperty('seller'); + expect(result.seller.id).toBe(mockSellerId); + }); + + it('should throw NotFoundError when seller does not exist', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.getSellerReviews(mockSellerId)) + .rejects.toThrow(NotFoundError); + }); + }); + + describe('updateReview', () => { + const mockReviewId = 'review-uuid'; + const mockBuyerId = 'buyer-uuid'; + const mockUpdateData = { rating: 4, comment: 'Updated comment' }; + + const mockReview = { + id: mockReviewId, + buyerId: mockBuyerId, + offer: { sellerId: 'seller-uuid' }, + buyer: { id: mockBuyerId, name: 'Buyer', walletAddress: 'wallet' }, + }; + + it('should update review successfully', async () => { + mockReviewRepository.findOne.mockResolvedValue(mockReview); + mockReviewRepository.save.mockResolvedValue({ ...mockReview, ...mockUpdateData }); + mockUserRepository.update.mockResolvedValue({}); + + const result = await service.updateReview(mockReviewId, mockBuyerId, mockUpdateData); + + expect(mockReviewRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockReviewId }, + relations: ['buyer', 'offer'], + }); + expect(result).toBeDefined(); + }); + + it('should throw NotFoundError when review does not exist', async () => { + mockReviewRepository.findOne.mockResolvedValue(null); + + await expect(service.updateReview(mockReviewId, mockBuyerId, mockUpdateData)) + .rejects.toThrow(NotFoundError); + }); + + it('should throw ForbiddenError when buyer is not the review owner', async () => { + const wrongBuyerId = 'wrong-buyer-uuid'; + mockReviewRepository.findOne.mockResolvedValue(mockReview); + + await expect(service.updateReview(mockReviewId, wrongBuyerId, mockUpdateData)) + .rejects.toThrow(ForbiddenError); + }); + }); + + describe('deleteReview', () => { + const mockReviewId = 'review-uuid'; + const mockBuyerId = 'buyer-uuid'; + + const mockReview = { + id: mockReviewId, + buyerId: mockBuyerId, + offer: { sellerId: 'seller-uuid' }, + }; + + it('should delete review successfully', async () => { + mockReviewRepository.findOne.mockResolvedValue(mockReview); + mockReviewRepository.delete.mockResolvedValue({ affected: 1 }); + mockUserRepository.update.mockResolvedValue({}); + + const result = await service.deleteReview(mockReviewId, mockBuyerId); + + expect(result).toBe(true); + expect(mockReviewRepository.delete).toHaveBeenCalledWith(mockReviewId); + }); + + it('should throw NotFoundError when review does not exist', async () => { + mockReviewRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteReview(mockReviewId, mockBuyerId)) + .rejects.toThrow(NotFoundError); + }); + + it('should throw ForbiddenError when buyer is not the review owner', async () => { + const wrongBuyerId = 'wrong-buyer-uuid'; + mockReviewRepository.findOne.mockResolvedValue(mockReview); + + await expect(service.deleteReview(mockReviewId, wrongBuyerId)) + .rejects.toThrow(ForbiddenError); + }); + }); +}); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 5750266..d1cca21 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -55,6 +55,12 @@ export class User { @OneToMany(() => Store, (store) => store.seller) stores: Store[]; + @Column({ type: 'decimal', precision: 3, scale: 2, nullable: true }) + averageSellerRating: number; + + @Column({ type: 'int', default: 0 }) + totalSellerReviews: number; + @CreateDateColumn() createdAt: Date;