Skip to content

Conversation

@sebastianperudev2001
Copy link

Solution for tech challenge

Copilot AI review requested due to automatic review settings January 8, 2026 03:27
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a complete anti-fraud transaction processing system for Yape's technical challenge. The solution uses event-driven architecture with NestJS, Kafka, and PostgreSQL to validate financial transactions in real-time.

Key Changes:

  • Implements dual API support (REST + GraphQL) for transaction management
  • Microservices architecture with Transaction API and Anti-Fraud Service
  • Event-driven communication using Kafka for asynchronous validation
  • CQRS pattern with clean architecture principles
  • Comprehensive test coverage with unit and E2E tests

Reviewed changes

Copilot reviewed 51 out of 53 changed files in this pull request and generated 25 comments.

Show a summary per file
File Description
package.json, tsconfig.json, nest-cli.json Workspace configuration for NestJS monorepo setup
docker-compose.yml Infrastructure setup with PostgreSQL, Kafka, and Zookeeper
libs/shared-types/ Shared TypeScript types for events across services
apps/transaction-api/ Main transaction API with REST/GraphQL endpoints, CQRS handlers, and Kafka integration
apps/anti-fraud-service/ Anti-fraud validation service that consumes and validates transactions
apps/transaction-api/prisma/ Database schema, migrations, and seed data
scripts/ Automation scripts for setup, starting services, and testing
README.md Comprehensive documentation with architecture diagrams and API examples

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Injectable()
export class FraudDetectionService {
private readonly logger = new Logger(FraudDetectionService.name);
private readonly AMOUNT_THRESHOLD = 1000;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The anti-fraud threshold of 1000 is hardcoded as a private class member. This business rule should be configurable via environment variables or a configuration file to allow different thresholds in different environments without code changes.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +361
it('debería crear una transacción válida con estado pending', async () => {
// Prepare: Arrange transaction data
const transactionDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
tranferTypeId: 1,
value: 500,
};

// Execute: Act - Create transaction
const response = await request(app.getHttpServer())
.post('/transactions')
.send(transactionDto)
.expect(201);

// Validate: Assert response structure and data
expect(response.body).toHaveProperty('transactionExternalId');
expect(response.body.transactionExternalId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);

expect(response.body.transactionType).toEqual({
name: 'Transfer',
});

expect(response.body.transactionStatus).toEqual({
name: 'pending',
});

expect(response.body.value).toBe(500);
expect(response.body.createdAt).toBeDefined();
expect(new Date(response.body.createdAt)).toBeInstanceOf(Date);
});

it('debería crear una transacción de alto valor con estado pending', async () => {
// Prepare: Arrange high-value transaction
const transactionDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440002',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440003',
tranferTypeId: 1,
value: 1500,
};

// Execute: Act
const response = await request(app.getHttpServer())
.post('/transactions')
.send(transactionDto)
.expect(201);

// Validate: Assert
expect(response.body.transactionStatus.name).toBe('pending');
expect(response.body.value).toBe(1500);
});

it('debería rechazar una transacción con valor negativo', async () => {
// Prepare: Arrange invalid transaction with negative value
const invalidDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
tranferTypeId: 1,
value: -100,
};

// Execute & Validate: Act & Assert
const response = await request(app.getHttpServer())
.post('/transactions')
.send(invalidDto)
.expect(400);

expect(response.body.message).toBeDefined();
});

it('debería rechazar una transacción sin accountExternalIdDebit', async () => {
// Prepare: Arrange incomplete transaction data
const invalidDto = {
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
tranferTypeId: 1,
value: 500,
};

// Execute & Validate: Act & Assert
await request(app.getHttpServer())
.post('/transactions')
.send(invalidDto)
.expect(400);
});

it('debería rechazar una transacción con UUID inválido', async () => {
// Prepare: Arrange transaction with invalid UUID
const invalidDto = {
accountExternalIdDebit: 'invalid-uuid',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
tranferTypeId: 1,
value: 500,
};

// Execute & Validate: Act & Assert
await request(app.getHttpServer())
.post('/transactions')
.send(invalidDto)
.expect(400);
});

it('debería rechazar una transacción con tranferTypeId inválido', async () => {
// Prepare: Arrange transaction with invalid type ID
const invalidDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
tranferTypeId: 999, // ID no existente
value: 500,
};

// Execute: Act
const response = await request(app.getHttpServer())
.post('/transactions')
.send(invalidDto);

// Validate: Assert - Should fail (400 or 404 depending on implementation)
expect([400, 404, 500]).toContain(response.status);
});
});

describe('GET /transactions/:id - Get Transaction', () => {
it('debería obtener una transacción existente por su ID', async () => {
// Prepare: Create a transaction first
const createDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440010',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440011',
tranferTypeId: 1,
value: 750,
};

const createResponse = await request(app.getHttpServer())
.post('/transactions')
.send(createDto)
.expect(201);

const transactionId = createResponse.body.transactionExternalId;

// Execute: Act - Get the transaction
const getResponse = await request(app.getHttpServer())
.get(`/transactions/${transactionId}`)
.expect(200);

// Validate: Assert
expect(getResponse.body.transactionExternalId).toBe(transactionId);
expect(getResponse.body.value).toBe(750);
expect(getResponse.body.transactionType).toEqual({
name: 'Transfer',
});
expect(getResponse.body.transactionStatus).toBeDefined();
});

it('debería retornar 404 para una transacción inexistente', async () => {
// Prepare: Arrange non-existent transaction ID
const nonExistentId = '550e8400-e29b-41d4-a716-999999999999';

// Execute & Validate: Act & Assert
await request(app.getHttpServer())
.get(`/transactions/${nonExistentId}`)
.expect(404);
});

it('debería retornar 400 para un ID inválido', async () => {
// Prepare: Arrange invalid transaction ID
const invalidId = 'not-a-valid-uuid';

// Execute: Act
const response = await request(app.getHttpServer())
.get(`/transactions/${invalidId}`);

// Validate: Assert - Should be 400 or 404
expect([400, 404]).toContain(response.status);
});
});

describe('Complete Transaction Flow with Anti-Fraud', () => {
it('debería procesar el flujo completo: crear → validar → actualizar estado (approved)', async () => {
// Prepare: Arrange transaction that should be approved (value <= 1000)
const transactionDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440020',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440021',
tranferTypeId: 1,
value: 800,
};

// Execute Step 1: Create transaction
const createResponse = await request(app.getHttpServer())
.post('/transactions')
.send(transactionDto)
.expect(201);

const transactionId = createResponse.body.transactionExternalId;

// Validate Step 1: Transaction created with pending status
expect(createResponse.body.transactionStatus.name).toBe('pending');

// Execute Step 2: Wait for anti-fraud processing
// En un entorno de pruebas real, esperarías a que Kafka procese el evento
await new Promise((resolve) => setTimeout(resolve, 3000));

// Execute Step 3: Get updated transaction
const getResponse = await request(app.getHttpServer())
.get(`/transactions/${transactionId}`)
.expect(200);

// Validate Step 3: Transaction should be approved
expect(getResponse.body.transactionExternalId).toBe(transactionId);
expect(getResponse.body.value).toBe(800);
// Note: En un test E2E real con Kafka, el estado debería ser 'approved'
// Si Kafka no está corriendo, seguirá siendo 'pending'
expect(['pending', 'approved']).toContain(getResponse.body.transactionStatus.name);
}, 10000); // Timeout aumentado para esperar procesamiento asíncrono

it('debería procesar el flujo completo: crear → validar → actualizar estado (rejected)', async () => {
// Prepare: Arrange transaction that should be rejected (value > 1000)
const transactionDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440022',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440023',
tranferTypeId: 1,
value: 1500,
};

// Execute Step 1: Create transaction
const createResponse = await request(app.getHttpServer())
.post('/transactions')
.send(transactionDto)
.expect(201);

const transactionId = createResponse.body.transactionExternalId;

// Validate Step 1: Transaction created with pending status
expect(createResponse.body.transactionStatus.name).toBe('pending');

// Execute Step 2: Wait for anti-fraud processing
await new Promise((resolve) => setTimeout(resolve, 3000));

// Execute Step 3: Get updated transaction
const getResponse = await request(app.getHttpServer())
.get(`/transactions/${transactionId}`)
.expect(200);

// Validate Step 3: Transaction should be rejected
expect(getResponse.body.transactionExternalId).toBe(transactionId);
expect(getResponse.body.value).toBe(1500);
// Note: En un test E2E real con Kafka, el estado debería ser 'rejected'
expect(['pending', 'rejected']).toContain(getResponse.body.transactionStatus.name);
}, 10000);

it('debería manejar correctamente el caso límite de $1000', async () => {
// Prepare: Arrange transaction with boundary value (exactly 1000)
const transactionDto = {
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440024',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440025',
tranferTypeId: 1,
value: 1000,
};

// Execute Step 1: Create transaction
const createResponse = await request(app.getHttpServer())
.post('/transactions')
.send(transactionDto)
.expect(201);

const transactionId = createResponse.body.transactionExternalId;

// Validate Step 1: Transaction created
expect(createResponse.body.transactionStatus.name).toBe('pending');
expect(createResponse.body.value).toBe(1000);

// Execute Step 2: Wait for processing
await new Promise((resolve) => setTimeout(resolve, 3000));

// Execute Step 3: Get updated transaction
const getResponse = await request(app.getHttpServer())
.get(`/transactions/${transactionId}`)
.expect(200);

// Validate Step 3: Transaction with value = 1000 should be approved
expect(['pending', 'approved']).toContain(getResponse.body.transactionStatus.name);
}, 10000);
});

describe('Multiple Concurrent Transactions', () => {
it('debería manejar múltiples transacciones concurrentes', async () => {
// Prepare: Arrange multiple transactions
const transactions = [
{
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440030',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440031',
tranferTypeId: 1,
value: 100,
},
{
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440032',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440033',
tranferTypeId: 1,
value: 200,
},
{
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440034',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440035',
tranferTypeId: 1,
value: 300,
},
];

// Execute: Act - Create all transactions concurrently
const createPromises = transactions.map((dto) =>
request(app.getHttpServer()).post('/transactions').send(dto),
);

const responses = await Promise.all(createPromises);

// Validate: Assert all transactions were created
responses.forEach((response) => {
expect(response.status).toBe(201);
expect(response.body.transactionExternalId).toBeDefined();
expect(response.body.transactionStatus.name).toBe('pending');
});

// Validate: All transactions have unique IDs
const ids = responses.map((r) => r.body.transactionExternalId);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(transactions.length);
});
});
});
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test descriptions are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. All test descriptions should be rewritten in English for consistency with the codebase standards.

Copilot generated this review using guidance from organization custom instructions.
Comment on lines +37 to +218
it('debería actualizar el estado de la transacción a approved cuando el evento indica aprobación', async () => {
// Prepare: Arrange approved transaction event
const event: TransactionValidatedEvent = {
eventId: 'event-123',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-123',
data: {
transactionExternalId: 'transaction-456',
status: 'approved',
validatedAt: '2024-01-15T10:00:00Z',
},
};

repository.updateStatus.mockResolvedValue(undefined);

// Execute: Act
await handler.handle(event);

// Validate: Assert
expect(repository.updateStatus).toHaveBeenCalledTimes(1);
expect(repository.updateStatus).toHaveBeenCalledWith(
'transaction-456',
2, // ID de estado "approved"
);
});

it('debería actualizar el estado de la transacción a rejected cuando el evento indica rechazo', async () => {
// Prepare: Arrange rejected transaction event
const event: TransactionValidatedEvent = {
eventId: 'event-789',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-789',
data: {
transactionExternalId: 'transaction-999',
status: 'rejected',
reason: 'Transaction amount 1500 exceeds threshold 1000',
validatedAt: '2024-01-15T10:00:00Z',
},
};

repository.updateStatus.mockResolvedValue(undefined);

// Execute: Act
await handler.handle(event);

// Validate: Assert
expect(repository.updateStatus).toHaveBeenCalledTimes(1);
expect(repository.updateStatus).toHaveBeenCalledWith(
'transaction-999',
3, // ID de estado "rejected"
);
});

it('debería propagar errores del repositorio', async () => {
// Prepare: Arrange error scenario
const event: TransactionValidatedEvent = {
eventId: 'event-error',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-error',
data: {
transactionExternalId: 'transaction-error',
status: 'approved',
validatedAt: '2024-01-15T10:00:00Z',
},
};

const dbError = new Error('Transaction not found');
repository.updateStatus.mockRejectedValue(dbError);

// Execute & Validate: Act & Assert
await expect(handler.handle(event)).rejects.toThrow('Transaction not found');

expect(repository.updateStatus).toHaveBeenCalledTimes(1);
});

it('debería manejar correctamente el mapeo de status approved a statusId 2', async () => {
// Prepare: Arrange approved event
const event: TransactionValidatedEvent = {
eventId: 'event-mapping-approved',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-mapping',
data: {
transactionExternalId: 'transaction-mapping-approved',
status: 'approved',
validatedAt: '2024-01-15T10:00:00Z',
},
};

repository.updateStatus.mockResolvedValue(undefined);

// Execute: Act
await handler.handle(event);

// Validate: Assert
const [transactionId, statusId] = repository.updateStatus.mock.calls[0];
expect(transactionId).toBe('transaction-mapping-approved');
expect(statusId).toBe(2); // approved
});

it('debería manejar correctamente el mapeo de status rejected a statusId 3', async () => {
// Prepare: Arrange rejected event
const event: TransactionValidatedEvent = {
eventId: 'event-mapping-rejected',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-mapping-rejected',
data: {
transactionExternalId: 'transaction-mapping-rejected',
status: 'rejected',
reason: 'Amount exceeds limit',
validatedAt: '2024-01-15T10:00:00Z',
},
};

repository.updateStatus.mockResolvedValue(undefined);

// Execute: Act
await handler.handle(event);

// Validate: Assert
const [transactionId, statusId] = repository.updateStatus.mock.calls[0];
expect(transactionId).toBe('transaction-mapping-rejected');
expect(statusId).toBe(3); // rejected
});

it('debería manejar eventos con campo reason opcional', async () => {
// Prepare: Arrange approved event without reason
const eventWithoutReason: TransactionValidatedEvent = {
eventId: 'event-no-reason',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-no-reason',
data: {
transactionExternalId: 'transaction-no-reason',
status: 'approved',
validatedAt: '2024-01-15T10:00:00Z',
},
};

repository.updateStatus.mockResolvedValue(undefined);

// Execute: Act
await handler.handle(eventWithoutReason);

// Validate: Assert
expect(repository.updateStatus).toHaveBeenCalledWith('transaction-no-reason', 2);

// Prepare: Arrange rejected event with reason
const eventWithReason: TransactionValidatedEvent = {
eventId: 'event-with-reason',
eventType: 'transaction.validated',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-with-reason',
data: {
transactionExternalId: 'transaction-with-reason',
status: 'rejected',
reason: 'Fraud detected',
validatedAt: '2024-01-15T10:00:00Z',
},
};

repository.updateStatus.mockResolvedValue(undefined);

// Execute: Act
await handler.handle(eventWithReason);

// Validate: Assert
expect(repository.updateStatus).toHaveBeenCalledWith('transaction-with-reason', 3);
});
});
});
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test descriptions are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. All test descriptions should be rewritten in English for consistency with the codebase standards.

Copilot generated this review using guidance from organization custom instructions.
Comment on lines +48 to +259
it('debería crear una transacción con estado pending y publicar evento a Kafka', async () => {
// Prepare: Arrange test data
const command = new CreateTransactionCommand(
'550e8400-e29b-41d4-a716-446655440000',
'550e8400-e29b-41d4-a716-446655440001',
1,
500,
);

const mockTransaction = {
transactionExternalId: 'mock-uuid-transaction',
accountExternalIdDebit: command.accountExternalIdDebit,
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
createdAt: new Date('2024-01-15T10:00:00Z'),
transactionType: {
name: 'Transfer',
},
transactionStatus: {
name: 'pending',
},
};

repository.create.mockResolvedValue(mockTransaction as any);
kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
const result = await handler.execute(command);

// Validate: Assert
// Verificar que se llamó al repositorio con los datos correctos
expect(repository.create).toHaveBeenCalledTimes(1);
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
accountExternalIdDebit: command.accountExternalIdDebit,
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
transactionStatusId: 1, // pending
}),
);

// Verificar que se publicó el evento a Kafka
expect(kafkaProducer.send).toHaveBeenCalledTimes(1);
expect(kafkaProducer.send).toHaveBeenCalledWith(
'transaction.created',
expect.objectContaining({
eventType: 'transaction.created',
eventVersion: '1.0',
data: expect.objectContaining({
transactionExternalId: mockTransaction.transactionExternalId,
accountExternalIdDebit: command.accountExternalIdDebit,
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
}),
}),
);

// Verificar la respuesta
expect(result).toEqual({
transactionExternalId: mockTransaction.transactionExternalId,
transactionType: {
name: 'Transfer',
},
transactionStatus: {
name: 'pending',
},
value: 500,
createdAt: mockTransaction.createdAt,
});
});

it('debería manejar transacciones con valores grandes correctamente', async () => {
// Prepare: Arrange test data with high value
const command = new CreateTransactionCommand(
'550e8400-e29b-41d4-a716-446655440000',
'550e8400-e29b-41d4-a716-446655440001',
1,
1500, // Valor mayor a 1000
);

const mockTransaction = {
transactionExternalId: 'mock-uuid-high-value',
accountExternalIdDebit: command.accountExternalIdDebit,
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
createdAt: new Date('2024-01-15T10:00:00Z'),
transactionType: {
name: 'Transfer',
},
transactionStatus: {
name: 'pending',
},
};

repository.create.mockResolvedValue(mockTransaction as any);
kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
const result = await handler.execute(command);

// Validate: Assert
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
value: 1500,
}),
);

expect(result.value).toBe(1500);
});

it('debería propagar errores del repositorio', async () => {
// Prepare: Arrange error scenario
const command = new CreateTransactionCommand(
'550e8400-e29b-41d4-a716-446655440000',
'550e8400-e29b-41d4-a716-446655440001',
1,
500,
);

const dbError = new Error('Database connection failed');
repository.create.mockRejectedValue(dbError);

// Execute & Validate: Act & Assert
await expect(handler.execute(command)).rejects.toThrow('Database connection failed');

// Verificar que Kafka no se llamó si falla el repositorio
expect(kafkaProducer.send).not.toHaveBeenCalled();
});

it('debería propagar errores de Kafka producer', async () => {
// Prepare: Arrange Kafka error scenario
const command = new CreateTransactionCommand(
'550e8400-e29b-41d4-a716-446655440000',
'550e8400-e29b-41d4-a716-446655440001',
1,
500,
);

const mockTransaction = {
transactionExternalId: 'mock-uuid-kafka-error',
accountExternalIdDebit: command.accountExternalIdDebit,
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
createdAt: new Date('2024-01-15T10:00:00Z'),
transactionType: {
name: 'Transfer',
},
transactionStatus: {
name: 'pending',
},
};

repository.create.mockResolvedValue(mockTransaction as any);
const kafkaError = new Error('Kafka broker not available');
kafkaProducer.send.mockRejectedValue(kafkaError);

// Execute & Validate: Act & Assert
await expect(handler.execute(command)).rejects.toThrow('Kafka broker not available');

// Verificar que el repositorio sí se llamó
expect(repository.create).toHaveBeenCalledTimes(1);
});

it('debería incluir eventId y correlationId en el evento publicado', async () => {
// Prepare: Arrange test data
const command = new CreateTransactionCommand(
'550e8400-e29b-41d4-a716-446655440000',
'550e8400-e29b-41d4-a716-446655440001',
1,
500,
);

const mockTransaction = {
transactionExternalId: 'mock-uuid-event-ids',
accountExternalIdDebit: command.accountExternalIdDebit,
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
createdAt: new Date('2024-01-15T10:00:00Z'),
transactionType: {
name: 'Transfer',
},
transactionStatus: {
name: 'pending',
},
};

repository.create.mockResolvedValue(mockTransaction as any);
kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await handler.execute(command);

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionCreatedEvent;

expect(publishedEvent.eventId).toBeDefined();
expect(publishedEvent.eventId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);

expect(publishedEvent.correlationId).toBeDefined();
expect(publishedEvent.correlationId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);

expect(publishedEvent.timestamp).toBeDefined();
expect(new Date(publishedEvent.timestamp)).toBeInstanceOf(Date);
});
});
});
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test descriptions are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. All test descriptions should be rewritten in English for consistency with the codebase standards.

Copilot generated this review using guidance from organization custom instructions.
Comment on lines +35 to +325
it('debería aprobar transacciones con valor menor o igual a 1000', async () => {
// Prepare: Arrange transaction with value <= 1000
const event: TransactionCreatedEvent = {
eventId: 'event-approved',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-approved',
data: {
transactionExternalId: 'transaction-approved',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 500,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
expect(kafkaProducer.send).toHaveBeenCalledTimes(1);
expect(kafkaProducer.send).toHaveBeenCalledWith(
'transaction.validated',
expect.objectContaining({
eventType: 'transaction.validated',
correlationId: 'correlation-approved',
data: expect.objectContaining({
transactionExternalId: 'transaction-approved',
status: 'approved',
reason: undefined,
}),
}),
);
});

it('debería aprobar transacciones con valor exactamente igual a 1000', async () => {
// Prepare: Arrange transaction with value = 1000 (boundary case)
const event: TransactionCreatedEvent = {
eventId: 'event-boundary',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-boundary',
data: {
transactionExternalId: 'transaction-boundary',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 1000,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionValidatedEvent;
expect(publishedEvent.data.status).toBe('approved');
expect(publishedEvent.data.reason).toBeUndefined();
});

it('debería rechazar transacciones con valor mayor a 1000', async () => {
// Prepare: Arrange transaction with value > 1000
const event: TransactionCreatedEvent = {
eventId: 'event-rejected',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-rejected',
data: {
transactionExternalId: 'transaction-rejected',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440002',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440003',
transferTypeId: 1,
value: 1500,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
expect(kafkaProducer.send).toHaveBeenCalledTimes(1);
expect(kafkaProducer.send).toHaveBeenCalledWith(
'transaction.validated',
expect.objectContaining({
eventType: 'transaction.validated',
correlationId: 'correlation-rejected',
data: expect.objectContaining({
transactionExternalId: 'transaction-rejected',
status: 'rejected',
reason: 'Transaction amount 1500 exceeds threshold 1000',
}),
}),
);
});

it('debería rechazar transacciones con valor inmediatamente superior a 1000', async () => {
// Prepare: Arrange transaction with value = 1001 (boundary case)
const event: TransactionCreatedEvent = {
eventId: 'event-boundary-rejected',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-boundary-rejected',
data: {
transactionExternalId: 'transaction-boundary-rejected',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440004',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440005',
transferTypeId: 1,
value: 1001,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionValidatedEvent;
expect(publishedEvent.data.status).toBe('rejected');
expect(publishedEvent.data.reason).toBe('Transaction amount 1001 exceeds threshold 1000');
});

it('debería incluir eventId y timestamp en el evento publicado', async () => {
// Prepare: Arrange transaction
const event: TransactionCreatedEvent = {
eventId: 'event-metadata',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-metadata',
data: {
transactionExternalId: 'transaction-metadata',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 750,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionValidatedEvent;

expect(publishedEvent.eventId).toBeDefined();
expect(publishedEvent.eventId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);

expect(publishedEvent.timestamp).toBeDefined();
expect(new Date(publishedEvent.timestamp)).toBeInstanceOf(Date);

expect(publishedEvent.eventVersion).toBe('1.0');
});

it('debería preservar el correlationId del evento original', async () => {
// Prepare: Arrange transaction with specific correlationId
const originalCorrelationId = 'correlation-preserve-test';
const event: TransactionCreatedEvent = {
eventId: 'event-correlation',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: originalCorrelationId,
data: {
transactionExternalId: 'transaction-correlation',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 300,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionValidatedEvent;
expect(publishedEvent.correlationId).toBe(originalCorrelationId);
});

it('debería propagar errores del KafkaProducer', async () => {
// Prepare: Arrange Kafka error scenario
const event: TransactionCreatedEvent = {
eventId: 'event-kafka-error',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-kafka-error',
data: {
transactionExternalId: 'transaction-kafka-error',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 500,
createdAt: '2024-01-15T10:00:00Z',
},
};

const kafkaError = new Error('Failed to send message to Kafka');
kafkaProducer.send.mockRejectedValue(kafkaError);

// Execute & Validate: Act & Assert
await expect(service.validateTransaction(event)).rejects.toThrow('Failed to send message to Kafka');
});

it('debería incluir validatedAt timestamp en el evento de validación', async () => {
// Prepare: Arrange transaction
const beforeValidation = new Date();

const event: TransactionCreatedEvent = {
eventId: 'event-validated-at',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-validated-at',
data: {
transactionExternalId: 'transaction-validated-at',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 250,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

const afterValidation = new Date();

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionValidatedEvent;

expect(publishedEvent.data.validatedAt).toBeDefined();
const validatedAt = new Date(publishedEvent.data.validatedAt);
expect(validatedAt).toBeInstanceOf(Date);
expect(validatedAt.getTime()).toBeGreaterThanOrEqual(beforeValidation.getTime());
expect(validatedAt.getTime()).toBeLessThanOrEqual(afterValidation.getTime());
});

it('debería manejar transacciones con valores decimales', async () => {
// Prepare: Arrange transaction with decimal value
const event: TransactionCreatedEvent = {
eventId: 'event-decimal',
eventType: 'transaction.created',
eventVersion: '1.0',
timestamp: '2024-01-15T10:00:00Z',
correlationId: 'correlation-decimal',
data: {
transactionExternalId: 'transaction-decimal',
accountExternalIdDebit: '550e8400-e29b-41d4-a716-446655440000',
accountExternalIdCredit: '550e8400-e29b-41d4-a716-446655440001',
transferTypeId: 1,
value: 999.99,
createdAt: '2024-01-15T10:00:00Z',
},
};

kafkaProducer.send.mockResolvedValue(undefined);

// Execute: Act
await service.validateTransaction(event);

// Validate: Assert
const publishedEvent = kafkaProducer.send.mock.calls[0][1] as TransactionValidatedEvent;
expect(publishedEvent.data.status).toBe('approved');
});
});
});
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test descriptions are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. All test descriptions should be rewritten in English for consistency with the codebase standards.

Copilot generated this review using guidance from organization custom instructions.
// Mapear status string a ID
const statusId = event.data.status === 'approved' ? 2 : 3;

// Actualizar status en DB
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. Comments should be translated to English for consistency.

Copilot generated this review using guidance from organization custom instructions.
? undefined
: `Transaction amount ${data.value} exceeds threshold ${this.AMOUNT_THRESHOLD}`;

// Crear evento de validación
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. Comments should be translated to English for consistency.

Copilot generated this review using guidance from organization custom instructions.

this.logger.log(`Processing transaction ${event.data.transactionExternalId}`);

// Validar transacción
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. Comments should be translated to English for consistency.

Copilot generated this review using guidance from organization custom instructions.
Comment on lines +10 to +32
// Transaction Types (datos de referencia)
model TransactionType {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(50)
description String?
createdAt DateTime @default(now())
transactions Transaction[]

@@map("transaction_types")
}

// Transaction Statuses (pending, approved, rejected)
model TransactionStatus {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(50)
description String?
createdAt DateTime @default(now())
transactions Transaction[]

@@map("transaction_statuses")
}

// Entidad principal: Transaction
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments are written in Spanish, which violates the coding guideline requiring all code, comments, and variable names to be in English. Comments should be translated to English for consistency.

Copilot generated this review using guidance from organization custom instructions.
accountExternalIdCredit: command.accountExternalIdCredit,
transferTypeId: command.transferTypeId,
value: command.value,
transactionStatusId: 1, // pending
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded status ID (1 for pending) is fragile and could break if database seed data changes. Consider fetching status IDs from the database or using a constant mapping to make the code more maintainable and less prone to errors.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant