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
6 changes: 6 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ import { Alert } from "./growth/alerts/entities/alert.entity";
import { AlertTriggerLog } from "./growth/alerts/entities/alert-trigger-log.entity";
import { AlertPreference } from "./growth/alerts/entities/alert-preference.entity";

// Discovery entities
import { AgentReview } from "./discovery/reviews/entities/agent-review.entity";
import { AgentReviewsModule } from "./discovery/reviews/agent-reviews.module";

// Guards
import { APP_FILTER } from "@nestjs/core";
import { ThrottlerUserIpGuard } from "./common/guard/throttler.guard";
Expand Down Expand Up @@ -165,6 +169,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware";
Alert,
AlertTriggerLog,
AlertPreference,
AgentReview,
],
synchronize: true,
logging: true,
Expand Down Expand Up @@ -202,6 +207,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware";
HealthModule,
ObservabilityModule,
ProfilingModule,
AgentReviewsModule,
],

controllers: [AppController],
Expand Down
87 changes: 87 additions & 0 deletions src/discovery/reviews/agent-reviews.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
Request,
} from "@nestjs/common";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
} from "@nestjs/swagger";
import { AgentReviewsService } from "./agent-reviews.service";
import {
CreateReviewDto,
DeveloperResponseDto,
ModerateReviewDto,
ReviewQueryDto,
} from "./dto/review.dto";
import { JwtAuthGuard } from "src/core/auth/jwt.guard";
import { Roles } from "src/common/guard/roles.decorator";
import { Role } from "src/common/guard/roles.enum";

@ApiTags("Agent Reviews")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller("api/discovery/reviews")
export class AgentReviewsController {
constructor(private readonly reviewsService: AgentReviewsService) {}

@Post()
@ApiOperation({ summary: "Submit a review for an agent" })
@ApiResponse({ status: 201, description: "Review submitted" })
create(@Request() req, @Body() dto: CreateReviewDto) {
// hasUsedAgent: in production, resolve via a usage-tracking service
return this.reviewsService.createReview(req.user.id, dto, true);
}

@Get("agent/:agentId")
@ApiOperation({ summary: "Get approved reviews for an agent" })
@ApiResponse({ status: 200, description: "Approved reviews" })
getApproved(@Param("agentId") agentId: string) {
return this.reviewsService.getApprovedReviews(agentId);
}

@Get("agent/:agentId/aggregation")
@ApiOperation({ summary: "Get rating aggregation for an agent" })
@ApiResponse({ status: 200, description: "Rating aggregation" })
getAggregation(@Param("agentId") agentId: string) {
return this.reviewsService.getAggregation(agentId);
}

@Patch(":reviewId/developer-response")
@ApiOperation({ summary: "Developer responds to a review" })
@ApiResponse({ status: 200, description: "Response added" })
developerResponse(
@Param("reviewId") reviewId: string,
@Request() req,
@Body() dto: DeveloperResponseDto,
) {
return this.reviewsService.addDeveloperResponse(reviewId, req.user.id, dto);
}

@Patch(":reviewId/moderate")
@Roles(Role.ADMIN)
@ApiOperation({ summary: "Moderate a review (admin only)" })
@ApiResponse({ status: 200, description: "Review moderated" })
moderate(
@Param("reviewId") reviewId: string,
@Body() dto: ModerateReviewDto,
) {
return this.reviewsService.moderateReview(reviewId, dto);
}

@Get("moderation")
@Roles(Role.ADMIN)
@ApiOperation({ summary: "Moderation dashboard — list reviews (admin only)" })
@ApiResponse({ status: 200, description: "Reviews for moderation" })
listForModeration(@Query() query: ReviewQueryDto) {
return this.reviewsService.listForModeration(query);
}
}
13 changes: 13 additions & 0 deletions src/discovery/reviews/agent-reviews.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AgentReview } from "./entities/agent-review.entity";
import { AgentReviewsService } from "./agent-reviews.service";
import { AgentReviewsController } from "./agent-reviews.controller";

@Module({
imports: [TypeOrmModule.forFeature([AgentReview])],
providers: [AgentReviewsService],
controllers: [AgentReviewsController],
exports: [AgentReviewsService],
})
export class AgentReviewsModule {}
127 changes: 127 additions & 0 deletions src/discovery/reviews/agent-reviews.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { ConflictException, ForbiddenException, NotFoundException } from "@nestjs/common";
import { AgentReviewsService } from "./agent-reviews.service";
import { AgentReview, ReviewStatus } from "./entities/agent-review.entity";

const mockRepo = () => ({
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
});

describe("AgentReviewsService", () => {
let service: AgentReviewsService;
let repo: ReturnType<typeof mockRepo>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentReviewsService,
{ provide: getRepositoryToken(AgentReview), useFactory: mockRepo },
],
}).compile();

service = module.get(AgentReviewsService);
repo = module.get(getRepositoryToken(AgentReview));
});

describe("createReview", () => {
const dto = { agentId: "agent-1", rating: 4, reviewText: "Great agent!" };

it("throws ForbiddenException if user has not used agent", async () => {
await expect(
service.createReview("user-1", dto, false),
).rejects.toThrow(ForbiddenException);
});

it("throws ConflictException if review already exists", async () => {
repo.findOne.mockResolvedValue({ id: "existing" });
await expect(
service.createReview("user-1", dto, true),
).rejects.toThrow(ConflictException);
});

it("creates review with PENDING status for clean text", async () => {
repo.findOne.mockResolvedValue(null);
const created = { ...dto, userId: "user-1", status: ReviewStatus.PENDING, spamScore: 0 };
repo.create.mockReturnValue(created);
repo.save.mockResolvedValue(created);

const result = await service.createReview("user-1", dto, true);
expect(result.status).toBe(ReviewStatus.PENDING);
expect(repo.save).toHaveBeenCalled();
});

it("flags review with FLAGGED status for spammy text", async () => {
repo.findOne.mockResolvedValue(null);
const spamDto = { ...dto, reviewText: "buy buy buy buy buy buy buy buy buy buy buy" };
repo.create.mockImplementation((data) => data);
repo.save.mockImplementation((data) => Promise.resolve(data));

const result = await service.createReview("user-1", spamDto, true);
expect(result.status).toBe(ReviewStatus.FLAGGED);
});
});

describe("getAggregation", () => {
it("returns zero aggregation when no approved reviews", async () => {
repo.find.mockResolvedValue([]);
const agg = await service.getAggregation("agent-1");
expect(agg.averageRating).toBe(0);
expect(agg.totalReviews).toBe(0);
});

it("calculates average rating correctly", async () => {
repo.find.mockResolvedValue([{ rating: 4 }, { rating: 2 }]);
const agg = await service.getAggregation("agent-1");
expect(agg.averageRating).toBe(3);
expect(agg.totalReviews).toBe(2);
});
});

describe("addDeveloperResponse", () => {
it("adds developer response to review", async () => {
const review = { id: "r1", developerResponse: null, developerRespondedAt: null };
repo.findOne.mockResolvedValue(review);
repo.save.mockImplementation((r) => Promise.resolve(r));

const result = await service.addDeveloperResponse(
"r1", "dev-1", { response: "Thanks!" }
);
expect(result.developerResponse).toBe("Thanks!");
expect(result.developerRespondedAt).toBeInstanceOf(Date);
});

it("throws NotFoundException for missing review", async () => {
repo.findOne.mockResolvedValue(null);
await expect(
service.addDeveloperResponse("bad-id", "dev-1", { response: "x" }),
).rejects.toThrow(NotFoundException);
});
});

describe("moderateReview", () => {
it("updates review status and note", async () => {
const review = { id: "r1", status: ReviewStatus.PENDING, moderationNote: null };
repo.findOne.mockResolvedValue(review);
repo.save.mockImplementation((r) => Promise.resolve(r));

const result = await service.moderateReview("r1", {
status: "approved",
moderationNote: "Looks good",
});
expect(result.status).toBe("approved");
expect(result.moderationNote).toBe("Looks good");
});
});

describe("getUserRatingForScoring", () => {
it("returns average rating for scoring engine", async () => {
repo.find.mockResolvedValue([{ rating: 5 }, { rating: 3 }]);
const rating = await service.getUserRatingForScoring("agent-1");
expect(rating).toBe(4);
});
});
});
Loading
Loading