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
40 changes: 23 additions & 17 deletions backend/src/access-logs/access-logs.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AccessLogsController } from './access-logs.controller';
import { AccessLogsService } from './access-logs.service';
import { FilterAccessLogsDto } from './dto/filter-access-logs.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';

describe('AccessLogsController', () => {
let controller: AccessLogsController;
let service: AccessLogsService;

const mockAccessLogsService = {
const mockService = {
findAll: jest.fn().mockResolvedValue({
data: [],
total: 0,
page: 1,
limit: 10,
limit: 50,
totalPages: 0,
}),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AccessLogsController],
providers: [
{
provide: AccessLogsService,
useValue: mockAccessLogsService,
},
],
providers: [{ provide: AccessLogsService, useValue: mockService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
Expand All @@ -35,19 +29,31 @@ describe('AccessLogsController', () => {
.compile();

controller = module.get<AccessLogsController>(AccessLogsController);
service = module.get<AccessLogsService>(AccessLogsService);
});

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

it('should return paginated access logs for admin user', async () => {
const filterDto: FilterAccessLogsDto = { page: 1, limit: 10 };
const result = await controller.getAccessLogs(filterDto);
it('should return filtered access logs', async () => {
const result = await controller.getAccessLogs({
userId: 'user-1',
httpMethod: 'GET',
page: 1,
limit: 25,
});
expect(result.data).toEqual([]);
expect(result.total).toBe(0);
expect(mockService.findAll).toHaveBeenCalledWith({
userId: 'user-1',
httpMethod: 'GET',
page: 1,
limit: 25,
});
});

expect(service.findAll).toHaveBeenCalledWith(filterDto);
expect(result).toHaveProperty('data');
expect(result).toHaveProperty('total');
it('should pass empty filters', async () => {
await controller.getAccessLogs({});
expect(mockService.findAll).toHaveBeenCalledWith({});
});
});
39 changes: 36 additions & 3 deletions backend/src/access-logs/dto/filter-access-logs.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,44 @@
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class FilterAccessLogsDto {
page?: number;
limit?: number;
sortByDateDesc?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;

@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 50;

@IsOptional()
sortByDateDesc?: boolean = true;

@IsOptional()
@IsString()
userId?: string;

@IsOptional()
@IsString()
routePath?: string;

@IsOptional()
@IsEnum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
httpMethod?: string;

@IsOptional()
@IsString()
ipAddress?: string;

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

@IsOptional()
@IsString()
endDate?: string;
}
18 changes: 18 additions & 0 deletions backend/src/dispute/dispute.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ForbiddenException,
Get,
Param,
Patch,
Post,
Query,
Req,
Expand All @@ -16,6 +17,7 @@ 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';
import { DisputeStatus } from './entities/dispute.entity';

@Controller('disputes')
@UseGuards(JwtAuthGuard)
Expand Down Expand Up @@ -73,4 +75,20 @@ export class DisputeController {

return dispute;
}

@Patch(':id/status')
async updateDisputeStatus(
@Param('id') id: string,
@Body('status') status: DisputeStatus,
@Req() req: Request & { user?: User },
): Promise<DisputeResponseDto> {
const user = req.user!;
if (user.role !== 'admin') {
throw new ForbiddenException(
'Only administrators can update a dispute status',
);
}

return this.disputeService.updateStatus(id, status, user.id);
}
}
107 changes: 107 additions & 0 deletions backend/src/dispute/dispute.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DisputeService } from './dispute.service';
import { Dispute, DisputeStatus } from './entities/dispute.entity';
import { DisputeReasonClassifierService } from './dispute-reason-classifier.service';
import { AccessLogsService } from '../access-logs/access-logs.service';
import { NotFoundException } from '@nestjs/common';

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

const mockRepo = () => ({
create: jest.fn().mockReturnValue(mockDispute),
save: jest.fn().mockResolvedValue(mockDispute),
findAndCount: jest.fn().mockResolvedValue([[mockDispute], 1]),
findOne: jest.fn().mockResolvedValue(null),
});

const mockClassifier = {
classifyDispute: jest.fn().mockResolvedValue(null),
};

const mockAccessLogs = {
logDocumentAccess: jest.fn().mockResolvedValue(undefined),
};

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

beforeEach(async () => {
repo = mockRepo();
const module: TestingModule = await Test.createTestingModule({
providers: [
DisputeService,
{ provide: getRepositoryToken(Dispute), useValue: repo },
{ provide: DisputeReasonClassifierService, useValue: mockClassifier },
{ provide: AccessLogsService, useValue: mockAccessLogs },
],
}).compile();

service = module.get<DisputeService>(DisputeService);
});

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

describe('fileDispute()', () => {
it('should create a dispute with OPEN status', async () => {
const result = await service.fileDispute(
{ documentId: 'doc-1', description: 'test' },
'user-1',
);
expect(result.status).toBe(DisputeStatus.OPEN);
expect(mockAccessLogs.logDocumentAccess).toHaveBeenCalled();
});
});

describe('updateStatus()', () => {
it('should update dispute status and audit log', async () => {
repo.findOne.mockResolvedValueOnce({ ...mockDispute });
const result = await service.updateStatus(
'dispute-1',
DisputeStatus.RESOLVED,
'admin-1',
);
expect(result.status).toBe(DisputeStatus.RESOLVED);
expect(mockAccessLogs.logDocumentAccess).toHaveBeenCalledWith(
'doc-1',
`dispute_status_changed:${DisputeStatus.OPEN}->${DisputeStatus.RESOLVED}`,
'admin-1',
);
});

it('should throw if dispute not found', async () => {
repo.findOne.mockResolvedValueOnce(null);
await expect(
service.updateStatus('nonexistent', DisputeStatus.RESOLVED, 'admin-1'),
).rejects.toThrow(NotFoundException);
});
});

describe('findOne()', () => {
it('should return dispute', async () => {
repo.findOne.mockResolvedValueOnce({ ...mockDispute });
const result = await service.findOne('dispute-1');
expect(result.id).toBe('dispute-1');
expect(result.status).toBe(DisputeStatus.OPEN);
});

it('should throw if not found', async () => {
repo.findOne.mockResolvedValueOnce(null);
await expect(service.findOne('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
});
36 changes: 35 additions & 1 deletion backend/src/dispute/dispute.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Dispute } from './entities/dispute.entity';
import { Dispute, DisputeStatus } from './entities/dispute.entity';
import { DisputeReasonClassifierService } from './dispute-reason-classifier.service';
import { CreateDisputeDto } from './dto/create-dispute.dto';
import { DisputeResponseDto } from './dto/dispute-response.dto';
import { AccessLogsService } from '../access-logs/access-logs.service';

@Injectable()
export class DisputeService {
constructor(
@InjectRepository(Dispute)
private readonly disputeRepo: Repository<Dispute>,
private readonly classifier: DisputeReasonClassifierService,
private readonly accessLogsService: AccessLogsService,
) {}

async fileDispute(
Expand All @@ -25,12 +27,43 @@ export class DisputeService {
description: dto.description,
reason,
filedBy: userId,
status: DisputeStatus.OPEN,
});

const saved = await this.disputeRepo.save(dispute);

await this.accessLogsService.logDocumentAccess(
dto.documentId,
`dispute_filed:${DisputeStatus.OPEN}`,
userId,
);

return this.toResponseDto(saved);
}

async updateStatus(
id: string,
newStatus: DisputeStatus,
userId: string,
): Promise<DisputeResponseDto> {
const dispute = await this.disputeRepo.findOne({ where: { id } });
if (!dispute) {
throw new NotFoundException(`Dispute ${id} not found`);
}

const oldStatus = dispute.status;
dispute.status = newStatus;
await this.disputeRepo.save(dispute);

await this.accessLogsService.logDocumentAccess(
dispute.documentId,
`dispute_status_changed:${oldStatus}->${newStatus}`,
userId,
);

return this.toResponseDto(dispute);
}

async findByUser(
userId: string,
limit = 20,
Expand Down Expand Up @@ -64,6 +97,7 @@ export class DisputeService {
description: dispute.description,
reason: dispute.reason,
filedBy: dispute.filedBy,
status: dispute.status,
createdAt: dispute.createdAt,
};
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/dispute/dto/dispute-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { DisputeReason } from '../entities/dispute-reason.entity';
import { DisputeStatus } from '../entities/dispute.entity';

export class DisputeResponseDto {
id: string;
documentId: string;
description: string;
reason: DisputeReason | null;
filedBy: string;
status: DisputeStatus;
createdAt: Date;
}
18 changes: 18 additions & 0 deletions backend/src/dispute/entities/dispute.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import {
Index,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { DisputeReason } from './dispute-reason.entity';

export enum DisputeStatus {
OPEN = 'open',
IN_REVIEW = 'in_review',
RESOLVED = 'resolved',
DISMISSED = 'dismissed',
}

@Entity('disputes')
@Index('IDX_DISPUTE_DOCUMENT', ['documentId'])
@Index('IDX_DISPUTE_FILED_BY', ['filedBy'])
Expand All @@ -27,6 +35,16 @@ export class Dispute {
@Column()
filedBy: string;

@Column({
type: 'enum',
enum: DisputeStatus,
default: DisputeStatus.OPEN,
})
status: DisputeStatus;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
Loading