Skip to content
Open
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
2 changes: 0 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,8 @@
"helmet": "^8.0.0",
"@nestjs/graphql": "^10.0.0",
"graphql": "^16.7.1",
"apollo-server-express": "^3.10.4",
"dataloader": "^2.2.2",
"graphql-depth-limit": "^1.1.0",
"graphql-query-complexity": "^0.8.0",
"compression": "^1.7.4"
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Dispute } from './dispute.entity';
import { AdminService } from './admin.service';
import { AdminController } from './admin.controller';
import { DisputeResolutionService } from './dispute-resolution.service';
import { AuditModule } from '../audit/audit.module';
import { UsersModule } from '../users/users.module';

@Module({
imports: [TypeOrmModule.forFeature([Dispute]), AuditModule, UsersModule],
providers: [AdminService],
providers: [DisputeResolutionService, AdminService],
controllers: [AdminController],
exports: [AdminService],
exports: [AdminService, DisputeResolutionService],
})
export class AdminModule {}
60 changes: 22 additions & 38 deletions apps/backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Dispute, DisputeStatus } from './dispute.entity';
/**
* AdminService (#813)
*
* Responsibility: Platform-level user management (ban, suspend, role change).
* Dispute-resolution logic has been extracted into DisputeResolutionService
* to reduce cyclomatic complexity and give each concern a focused boundary.
*
* Dispute methods are thin delegators kept here for backwards-compatibility
* with AdminController — the real logic lives in DisputeResolutionService.
*/

import { Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { UsersService } from '../users/users.service';
import { CreateDisputeDto, ResolveDisputeDto, SuspendUserDto } from './admin.dto';
import { AuditAction } from '../audit/audit-log.entity';
import { Dispute, DisputeStatus } from './dispute.entity';
import { DisputeResolutionService } from './dispute-resolution.service';

@Injectable()
export class AdminService {
constructor(
@InjectRepository(Dispute)
private readonly disputeRepo: Repository<Dispute>,
private readonly auditService: AuditService,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly disputeResolutionService: DisputeResolutionService,
) {}

// ── User management ───────────────────────────────────────────────────────
Expand All @@ -24,7 +33,7 @@ export class AdminService {
isBanned ? AuditAction.USER_BANNED : 'admin.user_unbanned',
adminId,
true,
{ targetUserId: targetId }
{ targetUserId: targetId },
);
return user;
}
Expand All @@ -33,7 +42,6 @@ export class AdminService {
const user = await this.usersService.findById(targetId);
if (!user) throw new NotFoundException('User not found');

// Store suspension as ban with metadata in audit log (no separate field needed)
const updated = await this.usersService.update(targetId, { isBanned: true });
await this.auditService.log('admin.user_suspended', adminId, true, {
targetUserId: targetId,
Expand All @@ -52,45 +60,21 @@ export class AdminService {
return user;
}

// ── Dispute management ────────────────────────────────────────────────────
// ── Dispute management (delegated) ────────────────────────────────────────

async createDispute(dto: CreateDisputeDto, userId: string): Promise<Dispute> {
const dispute = this.disputeRepo.create({
...dto,
submittedByUserId: userId,
status: DisputeStatus.OPEN,
});
const saved = await this.disputeRepo.save(dispute);
await this.auditService.log('admin.dispute_created', userId, true, { disputeId: saved.id });
return saved;
return this.disputeResolutionService.createDispute(dto, userId);
}

async listDisputes(status?: DisputeStatus): Promise<Dispute[]> {
const where = status ? { status } : {};
return this.disputeRepo.find({ where, order: { createdAt: 'DESC' } });
return this.disputeResolutionService.listDisputes(status);
}

async resolveDispute(id: string, dto: ResolveDisputeDto, adminId: string): Promise<Dispute> {
const dispute = await this.getDisputeOrThrow(id);
if (dispute.status === DisputeStatus.RESOLVED || dispute.status === DisputeStatus.CLOSED) {
throw new BadRequestException('Dispute already resolved or closed');
}

dispute.status = dto.status;
dispute.resolution = dto.resolution;
dispute.resolvedByUserId = adminId;
const saved = await this.disputeRepo.save(dispute);

await this.auditService.log('admin.dispute_resolved', adminId, true, {
disputeId: id,
status: dto.status,
});
return saved;
return this.disputeResolutionService.resolveDispute(id, dto, adminId);
}

async getDisputeOrThrow(id: string): Promise<Dispute> {
const dispute = await this.disputeRepo.findOne({ where: { id } });
if (!dispute) throw new NotFoundException('Dispute not found');
return dispute;
return this.disputeResolutionService.getDisputeOrThrow(id);
}
}
257 changes: 257 additions & 0 deletions apps/backend/src/admin/dispute-resolution.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/**
* Unit tests for DisputeResolutionService (#813).
*
* Verifies all dispute lifecycle operations — creation, listing, lookup,
* and resolution — in isolation with jest mocks (no DB or audit service).
*/

import { BadRequestException, NotFoundException } from '@nestjs/common';
import { DisputeResolutionService } from './dispute-resolution.service';
import { Dispute, DisputeStatus, DisputeType } from './dispute.entity';
import { CreateDisputeDto, ResolveDisputeDto } from './admin.dto';

// ─── helpers ─────────────────────────────────────────────────────────────────

function makeDispute(overrides: Partial<Dispute> = {}): Dispute {
return {
id: 'dispute-1',
type: DisputeType.OTHER,
status: DisputeStatus.OPEN,
submittedByUserId: 'user-1',
description: 'Test dispute',
targetEntityId: null,
targetEntityType: null,
resolvedByUserId: null,
resolution: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}

function makeRepo(overrides: Partial<Record<string, jest.Mock>> = {}) {
return {
findOne: jest.fn().mockResolvedValue(null),
find: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation((data: any) => ({ ...data })),
save: jest.fn().mockImplementation(async (r: any) => r),
...overrides,
};
}

function makeAuditService() {
return { log: jest.fn().mockResolvedValue(undefined) };
}

function makeService(repoOverrides: Partial<Record<string, jest.Mock>> = {}) {
const repo = makeRepo(repoOverrides);
const auditService = makeAuditService();
return {
service: new DisputeResolutionService(repo as any, auditService as any),
repo,
auditService,
};
}

// ═════════════════════════════════════════════════════════════════════════════
// createDispute
// ═════════════════════════════════════════════════════════════════════════════

describe('DisputeResolutionService.createDispute', () => {
it('creates a dispute with OPEN status and logs an audit event', async () => {
const savedDispute = makeDispute({ id: 'new-dispute' });
const { service, repo, auditService } = makeService({
save: jest.fn().mockResolvedValue(savedDispute),
});

const dto: CreateDisputeDto = {
type: DisputeType.BILLING,
description: 'Incorrect charge',
};

const result = await service.createDispute(dto, 'user-1');

expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: DisputeStatus.OPEN,
submittedByUserId: 'user-1',
}),
);
expect(repo.save).toHaveBeenCalledTimes(1);
expect(result.id).toBe('new-dispute');
expect(auditService.log).toHaveBeenCalledWith(
'admin.dispute_created',
'user-1',
true,
expect.objectContaining({ disputeId: 'new-dispute' }),
);
});
});

// ═════════════════════════════════════════════════════════════════════════════
// listDisputes
// ═════════════════════════════════════════════════════════════════════════════

describe('DisputeResolutionService.listDisputes', () => {
it('returns all disputes when no status filter is provided', async () => {
const disputes = [makeDispute(), makeDispute({ id: 'd2', status: DisputeStatus.RESOLVED })];
const { service, repo } = makeService({
find: jest.fn().mockResolvedValue(disputes),
});

const result = await service.listDisputes();

expect(repo.find).toHaveBeenCalledWith({ where: {}, order: { createdAt: 'DESC' } });
expect(result).toHaveLength(2);
});

it('filters disputes by status when provided', async () => {
const openDisputes = [makeDispute()];
const { service, repo } = makeService({
find: jest.fn().mockResolvedValue(openDisputes),
});

const result = await service.listDisputes(DisputeStatus.OPEN);

expect(repo.find).toHaveBeenCalledWith({
where: { status: DisputeStatus.OPEN },
order: { createdAt: 'DESC' },
});
expect(result).toHaveLength(1);
});
});

// ═════════════════════════════════════════════════════════════════════════════
// getDisputeOrThrow
// ═════════════════════════════════════════════════════════════════════════════

describe('DisputeResolutionService.getDisputeOrThrow', () => {
it('returns the dispute when it exists', async () => {
const dispute = makeDispute({ id: 'exists' });
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(dispute),
});

const result = await service.getDisputeOrThrow('exists');
expect(result.id).toBe('exists');
});

it('throws NotFoundException when the dispute does not exist', async () => {
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(null),
});

await expect(service.getDisputeOrThrow('missing')).rejects.toThrow(NotFoundException);
});
});

// ═════════════════════════════════════════════════════════════════════════════
// resolveDispute
// ═════════════════════════════════════════════════════════════════════════════

describe('DisputeResolutionService.resolveDispute', () => {
it('resolves an open dispute and logs an audit event', async () => {
const dispute = makeDispute({ status: DisputeStatus.OPEN });
const { service, auditService } = makeService({
findOne: jest.fn().mockResolvedValue(dispute),
save: jest.fn().mockImplementation(async (r: any) => r),
});

const dto: ResolveDisputeDto = {
status: DisputeStatus.RESOLVED,
resolution: 'Refund issued',
};

const result = await service.resolveDispute('dispute-1', dto, 'admin-1');

expect(result.status).toBe(DisputeStatus.RESOLVED);
expect(result.resolution).toBe('Refund issued');
expect(result.resolvedByUserId).toBe('admin-1');
expect(auditService.log).toHaveBeenCalledWith(
'admin.dispute_resolved',
'admin-1',
true,
expect.objectContaining({ status: DisputeStatus.RESOLVED }),
);
});

it('resolves an under_review dispute (not just open)', async () => {
const dispute = makeDispute({ status: DisputeStatus.UNDER_REVIEW });
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(dispute),
save: jest.fn().mockImplementation(async (r: any) => r),
});

const dto: ResolveDisputeDto = {
status: DisputeStatus.CLOSED,
resolution: 'No action needed',
};

const result = await service.resolveDispute('dispute-1', dto, 'admin-1');
expect(result.status).toBe(DisputeStatus.CLOSED);
});

it('throws BadRequestException when dispute is already resolved', async () => {
const dispute = makeDispute({ status: DisputeStatus.RESOLVED });
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(dispute),
});

const dto: ResolveDisputeDto = {
status: DisputeStatus.CLOSED,
resolution: 'Another attempt',
};

await expect(service.resolveDispute('dispute-1', dto, 'admin-1')).rejects.toThrow(
BadRequestException,
);
});

it('throws BadRequestException when dispute is already closed', async () => {
const dispute = makeDispute({ status: DisputeStatus.CLOSED });
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(dispute),
});

const dto: ResolveDisputeDto = {
status: DisputeStatus.RESOLVED,
resolution: 'Too late',
};

await expect(service.resolveDispute('dispute-1', dto, 'admin-1')).rejects.toThrow(
BadRequestException,
);
});

it('throws BadRequestException when resolution status is not terminal', async () => {
const dispute = makeDispute({ status: DisputeStatus.OPEN });
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(dispute),
});

// Trying to "resolve" to UNDER_REVIEW is not allowed
const dto = {
status: DisputeStatus.UNDER_REVIEW,
resolution: 'This should fail',
} as unknown as ResolveDisputeDto;

await expect(service.resolveDispute('dispute-1', dto, 'admin-1')).rejects.toThrow(
BadRequestException,
);
});

it('throws NotFoundException when the dispute does not exist', async () => {
const { service } = makeService({
findOne: jest.fn().mockResolvedValue(null),
});

const dto: ResolveDisputeDto = {
status: DisputeStatus.RESOLVED,
resolution: 'Whatever',
};

await expect(service.resolveDispute('ghost', dto, 'admin-1')).rejects.toThrow(
NotFoundException,
);
});
});
Loading