Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 41 additions & 0 deletions backend/src/common/dto/pagination.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { PaginationQueryDto } from './pagination.dto';

describe('PaginationQueryDto', () => {
it('should accept valid pagination params', async () => {
const dto = plainToInstance(PaginationQueryDto, {
page: 1,
limit: 20,
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('should accept limit at max (100)', async () => {
const dto = plainToInstance(PaginationQueryDto, { limit: 100 });
const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('should reject limit above max (100)', async () => {
const dto = plainToInstance(PaginationQueryDto, { limit: 1000 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitErrors = errors.filter((e) => e.property === 'limit');
expect(limitErrors.length).toBeGreaterThan(0);
});

it('should reject page less than 1', async () => {
const dto = plainToInstance(PaginationQueryDto, { page: 0 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});

it('should use defaults when no params provided', () => {
const dto = plainToInstance(PaginationQueryDto, {});
expect(dto.page).toBe(1);
expect(dto.limit).toBe(20);
expect(dto.sortOrder).toBe('DESC');
});
});
114 changes: 114 additions & 0 deletions backend/src/dispute/dispute.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DisputeController } from './dispute.controller';
import { DisputeService } from './dispute.service';
import { DocumentsService } from '../documents/documents.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ForbiddenException } from '@nestjs/common';

const mockDispute = {
id: 'dispute-1',
documentId: 'doc-1',
description: 'Invalid signature',
reason: null,
filedBy: 'user-1',
createdAt: new Date(),
};

const mockDocument = {
id: 'doc-1',
ownerId: 'user-1',
title: 'Land Deed',
status: 'pending',
};

const mockDisputeService = {
fileDispute: jest.fn().mockImplementation((_dto, userId) =>
Promise.resolve({ ...mockDispute, filedBy: userId }),
),
findByUser: jest.fn().mockResolvedValue({ data: [mockDispute], total: 1 }),
findOne: jest.fn().mockImplementation((id) =>
Promise.resolve({ ...mockDispute, id }),
),
};

const mockDocumentsService = {
findById: jest.fn().mockResolvedValue(mockDocument),
};

describe('DisputeController', () => {
let controller: DisputeController;

beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [DisputeController],
providers: [
{ provide: DisputeService, useValue: mockDisputeService },
{ provide: DocumentsService, useValue: mockDocumentsService },
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get<DisputeController>(DisputeController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('fileDispute()', () => {
it('should allow owner to file dispute on own document', async () => {
const result = await controller.fileDispute(
{ documentId: 'doc-1', description: 'test' } as any,
{ user: { id: 'user-1', role: 'user' } } as any,
);
expect(result.filedBy).toBe('user-1');
});

it('should allow admin to file dispute on any document', async () => {
const result = await controller.fileDispute(
{ documentId: 'doc-1', description: 'test' } as any,
{ user: { id: 'admin-1', role: 'admin' } } as any,
);
expect(result.filedBy).toBe('admin-1');
});

it('should reject non-owner non-admin filing dispute', async () => {
await expect(
controller.fileDispute(
{ documentId: 'doc-1', description: 'test' } as any,
{ user: { id: 'user-2', role: 'user' } } as any,
),
).rejects.toThrow(ForbiddenException);
});

it('should reject if document not found', async () => {
mockDocumentsService.findById.mockResolvedValueOnce(null);
await expect(
controller.fileDispute(
{ documentId: 'nonexistent', description: 'test' } as any,
{ user: { id: 'user-1', role: 'user' } } as any,
),
).rejects.toThrow(ForbiddenException);
});
});

describe('getDispute()', () => {
it('should allow owner to view own dispute', async () => {
const result = await controller.getDispute('dispute-1', {
user: { id: 'user-1', role: 'user' },
} as any);
expect(result.id).toBe('dispute-1');
});

it('should reject non-owner viewing dispute', async () => {
await expect(
controller.getDispute('dispute-1', {
user: { id: 'user-2', role: 'user' },
} as any),
).rejects.toThrow(ForbiddenException);
});
});
});
36 changes: 32 additions & 4 deletions backend/src/dispute/dispute.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Post,
Expand All @@ -11,21 +12,38 @@ import {
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { User } from '../users/entities/user.entity';
import { DocumentsService } from '../documents/documents.service';
import { CreateDisputeDto } from './dto/create-dispute.dto';
import { DisputeResponseDto } from './dto/dispute-response.dto';
import { DisputeService } from './dispute.service';

@Controller('disputes')
@UseGuards(JwtAuthGuard)
export class DisputeController {
constructor(private readonly disputeService: DisputeService) {}
constructor(
private readonly disputeService: DisputeService,
private readonly documentsService: DocumentsService,
) {}

@Post()
async fileDispute(
@Body() dto: CreateDisputeDto,
@Req() req: Request & { user?: User },
): Promise<DisputeResponseDto> {
return this.disputeService.fileDispute(dto, req.user!.id);
const user = req.user!;
const document = await this.documentsService.findById(dto.documentId);

if (!document) {
throw new ForbiddenException('Document not found');
}

if (document.ownerId !== user.id && user.role !== 'admin') {
throw new ForbiddenException(
'You can only file disputes on your own documents',
);
}

return this.disputeService.fileDispute(dto, user.id);
}

@Get()
Expand All @@ -42,7 +60,17 @@ export class DisputeController {
}

@Get(':id')
async getDispute(@Param('id') id: string): Promise<DisputeResponseDto> {
return this.disputeService.findOne(id);
async getDispute(
@Param('id') id: string,
@Req() req: Request & { user?: User },
): Promise<DisputeResponseDto> {
const dispute = await this.disputeService.findOne(id);
const user = req.user!;

if (dispute.filedBy !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Access denied');
}

return dispute;
}
}
47 changes: 46 additions & 1 deletion backend/src/documents/documents.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { Document, DocumentStatus } from './entities/document.entity';

@Injectable()
Expand Down Expand Up @@ -79,4 +79,49 @@ export class DocumentsService {
.andWhere('document.longitude IS NOT NULL')
.getMany();
}

async findByRiskFilters(filters: {
page: number;
limit: number;
minScore?: number;
maxScore?: number;
startDate?: Date;
endDate?: Date;
sortOrder: 'ASC' | 'DESC';
}): Promise<{ data: Document[]; total: number }> {
const qb = this.documentRepository.createQueryBuilder('document');

qb.where('document.risk_score IS NOT NULL');

if (filters.minScore !== undefined) {
qb.andWhere('document.risk_score >= :minScore', {
minScore: filters.minScore,
});
}

if (filters.maxScore !== undefined) {
qb.andWhere('document.risk_score <= :maxScore', {
maxScore: filters.maxScore,
});
}

if (filters.startDate) {
qb.andWhere('document.created_at >= :startDate', {
startDate: filters.startDate,
});
}

if (filters.endDate) {
qb.andWhere('document.created_at <= :endDate', {
endDate: filters.endDate,
});
}

qb.orderBy('document.risk_score', filters.sortOrder);
qb.skip((filters.page - 1) * filters.limit);
qb.take(filters.limit);

const [data, total] = await qb.getManyAndCount();
return { data, total };
}
}
50 changes: 50 additions & 0 deletions backend/src/risk-assessment/dto/list-risk-assessments.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';

export class ListRiskAssessmentsDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;

@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;

@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
minScore?: number;

@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(100)
maxScore?: number;

@ApiPropertyOptional()
@IsOptional()
@IsString()
startDate?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
endDate?: string;

@ApiPropertyOptional({ enum: ['ASC', 'DESC'] })
@IsOptional()
@IsString()
sortOrder?: 'ASC' | 'DESC' = 'DESC';
}
40 changes: 38 additions & 2 deletions backend/src/risk-assessment/risk-assessment.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';

import { RiskAssessmentService } from './risk-assessment.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ListRiskAssessmentsDto } from './dto/list-risk-assessments.dto';
import { DocumentsService } from '../documents/documents.service';

@Controller('documents')
export class RiskAssessmentController {
constructor(private readonly riskService: RiskAssessmentService) {}
constructor(
private readonly riskService: RiskAssessmentService,
private readonly documentsService: DocumentsService,
) {}

@Get(':id/risk')
@UseGuards(JwtAuthGuard)
async getRisk(@Param('id') id: string) {
return this.riskService.assessDocument(id);
}

@Get('risk-assessments')
@UseGuards(JwtAuthGuard)
async listRiskAssessments(@Query() query: ListRiskAssessmentsDto) {
const { page = 1, limit = 20, minScore, maxScore, startDate, endDate, sortOrder = 'DESC' } = query;

const result = await this.documentsService.findByRiskFilters({
page,
limit,
minScore,
maxScore,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
sortOrder,
});

return {
data: result.data.map((doc) => ({
documentId: doc.id,
title: doc.title,
riskScore: doc.riskScore,
riskFlags: doc.riskFlags,
status: doc.status,
createdAt: doc.createdAt,
})),
total: result.total,
page,
limit,
totalPages: Math.ceil(result.total / limit),
};
}
}
Loading