tests: add unit test fo fabric.service.ts. fix: add better handling for connection failure and better handling when disconnecting from fabric on fabric service

This commit is contained in:
yosaphatprs 2025-12-03 11:00:41 +07:00
parent 7b0873e0da
commit 87e20b6848
4 changed files with 456 additions and 12 deletions

View File

@ -28,7 +28,6 @@ export class AuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(jwtToken, {
secret: this.configService.get<string>('JWT_SECRET'),
});
console.log(payload);
if (payload.csrf !== csrfToken) {
throw new UnauthorizedException(['Invalid CSRF token']);

View File

@ -1,8 +1,16 @@
import { Module } from '@nestjs/common';
import { Module, Logger } from '@nestjs/common';
import { FabricService } from './fabric.service';
import FabricGateway, { fabricGateway } from '../../common/fabric-gateway';
@Module({
providers: [FabricService],
providers: [
FabricService,
Logger,
{
provide: FabricGateway,
useValue: fabricGateway,
},
],
exports: [FabricService],
})
export class FabricModule {}

View File

@ -1,18 +1,426 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import { FabricService } from './fabric.service';
import FabricGateway from '@api/common/fabric-gateway';
describe('FabricService', () => {
let service: FabricService;
let mockGateway: {
connect: jest.Mock;
disconnect: jest.Mock;
storeLog: jest.Mock;
getLogById: jest.Mock;
getAllLogs: jest.Mock;
getLogsWithPagination: jest.Mock;
};
let mockLogger: {
log: jest.Mock;
error: jest.Mock;
warn: jest.Mock;
debug: jest.Mock;
};
beforeEach(async () => {
// Reset all mocks before each test
jest.clearAllMocks();
// Create mock gateway
mockGateway = {
connect: jest.fn(),
disconnect: jest.fn(),
storeLog: jest.fn(),
getLogById: jest.fn(),
getAllLogs: jest.fn(),
getLogsWithPagination: jest.fn(),
};
// Create mock logger
mockLogger = {
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [FabricService],
providers: [
FabricService,
{
provide: FabricGateway,
useValue: mockGateway,
},
{
provide: Logger,
useValue: mockLogger,
},
],
}).compile();
service = module.get<FabricService>(FabricService);
});
it('should be defined', () => {
expect(service).toBeDefined();
describe('constructor', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
});
// =====================================================================
// LIFECYCLE HOOKS
// =====================================================================
describe('onModuleInit', () => {
it('should connect to Fabric network on module init', async () => {
mockGateway.connect.mockResolvedValue(true);
await service.onModuleInit();
expect(mockGateway.connect).toHaveBeenCalledTimes(1);
});
it('should throw error when connection fails', async () => {
const connectionError = new Error('Connection refused');
mockGateway.connect.mockRejectedValue(connectionError);
await expect(service.onModuleInit()).rejects.toThrow(
'Failed to connect to Fabric network: Connection refused',
);
expect(mockGateway.connect).toHaveBeenCalledTimes(1);
});
it('should include original error message in thrown error', async () => {
const originalError = new Error('ECONNREFUSED: localhost:7051');
mockGateway.connect.mockRejectedValue(originalError);
try {
await service.onModuleInit();
fail('Should have thrown');
} catch (error: any) {
expect(error.message).toContain('ECONNREFUSED');
expect(error.message).toBe(
'Failed to connect to Fabric network: ECONNREFUSED: localhost:7051',
);
}
});
it('should handle non-Error objects gracefully', async () => {
mockGateway.connect.mockRejectedValue('String error');
await expect(service.onModuleInit()).rejects.toThrow(
'Failed to connect to Fabric network: Unknown error',
);
});
});
describe('onApplicationShutdown', () => {
it('should disconnect from Fabric network on shutdown', async () => {
mockGateway.disconnect.mockResolvedValue(undefined);
await service.onApplicationShutdown();
expect(mockGateway.disconnect).toHaveBeenCalledTimes(1);
});
it('should disconnect with signal parameter', async () => {
mockGateway.disconnect.mockResolvedValue(undefined);
await service.onApplicationShutdown('SIGTERM');
expect(mockGateway.disconnect).toHaveBeenCalledTimes(1);
});
it('should not throw error if disconnect fails - just log it', async () => {
const disconnectError = new Error('Disconnect failed');
mockGateway.disconnect.mockRejectedValue(disconnectError);
// Should NOT throw - graceful shutdown
await expect(service.onApplicationShutdown()).resolves.not.toThrow();
expect(mockGateway.disconnect).toHaveBeenCalledTimes(1);
});
it('should handle non-Error objects during disconnect gracefully', async () => {
mockGateway.disconnect.mockRejectedValue('String error');
await expect(service.onApplicationShutdown()).resolves.not.toThrow();
});
});
// =====================================================================
// storeLog
// =====================================================================
describe('storeLog', () => {
const mockStoreLogResult = {
transactionId: 'tx123',
status: 'COMMITTED',
};
it('should store log with all parameters', async () => {
mockGateway.storeLog.mockResolvedValue(mockStoreLogResult);
const result = await service.storeLog(
'log-1',
'CREATE',
'user-1',
'{"data": "test"}',
);
expect(mockGateway.storeLog).toHaveBeenCalledWith(
'log-1',
'CREATE',
'user-1',
'{"data": "test"}',
);
expect(result).toEqual(mockStoreLogResult);
});
it('should propagate errors from gateway', async () => {
const storeError = new Error('Transaction failed');
mockGateway.storeLog.mockRejectedValue(storeError);
await expect(
service.storeLog('log-1', 'CREATE', 'user-1', '{}'),
).rejects.toThrow('Transaction failed');
});
it('should not validate empty id (NO VALIDATION)', async () => {
mockGateway.storeLog.mockResolvedValue(mockStoreLogResult);
// Empty ID passes through without validation
await service.storeLog('', 'CREATE', 'user-1', '{}');
expect(mockGateway.storeLog).toHaveBeenCalledWith(
'',
'CREATE',
'user-1',
'{}',
);
});
it('should not validate empty event (NO VALIDATION)', async () => {
mockGateway.storeLog.mockResolvedValue(mockStoreLogResult);
await service.storeLog('log-1', '', 'user-1', '{}');
expect(mockGateway.storeLog).toHaveBeenCalledWith(
'log-1',
'',
'user-1',
'{}',
);
});
it('should not validate empty user_id (NO VALIDATION)', async () => {
mockGateway.storeLog.mockResolvedValue(mockStoreLogResult);
await service.storeLog('log-1', 'CREATE', '', '{}');
expect(mockGateway.storeLog).toHaveBeenCalledWith(
'log-1',
'CREATE',
'',
'{}',
);
});
it('should not validate malformed JSON payload (NO VALIDATION)', async () => {
mockGateway.storeLog.mockResolvedValue(mockStoreLogResult);
// Invalid JSON passes through
await service.storeLog('log-1', 'CREATE', 'user-1', 'not-valid-json');
expect(mockGateway.storeLog).toHaveBeenCalledWith(
'log-1',
'CREATE',
'user-1',
'not-valid-json',
);
});
});
// =====================================================================
// getLogById
// =====================================================================
describe('getLogById', () => {
const mockLog = {
id: 'log-1',
event: 'CREATE',
user_id: 'user-1',
payload: '{}',
timestamp: '2024-01-01T00:00:00Z',
};
it('should retrieve log by id', async () => {
mockGateway.getLogById.mockResolvedValue(mockLog);
const result = await service.getLogById('log-1');
expect(mockGateway.getLogById).toHaveBeenCalledWith('log-1');
expect(result).toEqual(mockLog);
});
it('should propagate errors from gateway', async () => {
const notFoundError = new Error('Log not found');
mockGateway.getLogById.mockRejectedValue(notFoundError);
await expect(service.getLogById('non-existent')).rejects.toThrow(
'Log not found',
);
});
/**
* ISSUE FOUND: No validation for empty or null id.
*/
it('should not validate empty id (NO VALIDATION)', async () => {
mockGateway.getLogById.mockResolvedValue(null);
await service.getLogById('');
expect(mockGateway.getLogById).toHaveBeenCalledWith('');
});
});
// =====================================================================
// getAllLogs
// =====================================================================
describe('getAllLogs', () => {
const mockLogs = [
{ id: 'log-1', event: 'CREATE' },
{ id: 'log-2', event: 'UPDATE' },
];
it('should retrieve all logs', async () => {
mockGateway.getAllLogs.mockResolvedValue(mockLogs);
const result = await service.getAllLogs();
expect(mockGateway.getAllLogs).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockLogs);
});
it('should return empty array when no logs exist', async () => {
mockGateway.getAllLogs.mockResolvedValue([]);
const result = await service.getAllLogs();
expect(result).toEqual([]);
});
it('should propagate errors from gateway', async () => {
const queryError = new Error('Query failed');
mockGateway.getAllLogs.mockRejectedValue(queryError);
await expect(service.getAllLogs()).rejects.toThrow('Query failed');
});
});
// =====================================================================
// getLogsWithPagination
// =====================================================================
describe('getLogsWithPagination', () => {
const mockPaginatedResult = {
records: [{ id: 'log-1' }, { id: 'log-2' }],
bookmark: 'next-page-bookmark',
fetchedRecordsCount: 2,
};
it('should retrieve logs with pagination', async () => {
mockGateway.getLogsWithPagination.mockResolvedValue(mockPaginatedResult);
const result = await service.getLogsWithPagination(10, '');
expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(10, '');
expect(result).toEqual(mockPaginatedResult);
});
it('should pass bookmark for subsequent pages', async () => {
mockGateway.getLogsWithPagination.mockResolvedValue(mockPaginatedResult);
await service.getLogsWithPagination(10, 'page-2-bookmark');
expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(
10,
'page-2-bookmark',
);
});
it('should propagate errors from gateway', async () => {
const paginationError = new Error('Pagination failed');
mockGateway.getLogsWithPagination.mockRejectedValue(paginationError);
await expect(service.getLogsWithPagination(10, '')).rejects.toThrow(
'Pagination failed',
);
});
/**
* ISSUE FOUND: No validation for pageSize.
* Negative, zero, or extremely large values pass through.
*/
it('should not validate zero pageSize (NO VALIDATION)', async () => {
mockGateway.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(0, '');
expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(0, '');
});
it('should not validate negative pageSize (NO VALIDATION)', async () => {
mockGateway.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(-5, '');
expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(-5, '');
});
it('should not validate extremely large pageSize (NO VALIDATION)', async () => {
mockGateway.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(999999999, '');
expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(
999999999,
'',
);
});
});
describe('Code Review Issues', () => {
it('should use dependency injection for FabricGateway', () => {
expect(service).toBeDefined();
// Gateway is now injected, we can test it directly
expect(mockGateway.connect).toBeDefined();
});
it('should document that errors are not transformed (MISSING ERROR HANDLING)', async () => {
const rawError = new Error('Raw gateway error');
mockGateway.storeLog.mockRejectedValue(rawError);
// Error passes through unchanged - no NestJS exception wrapping
await expect(
service.storeLog('log-1', 'CREATE', 'user-1', '{}'),
).rejects.toThrow('Raw gateway error');
});
it('should accept signal parameter for shutdown logging', async () => {
mockGateway.disconnect.mockResolvedValue(undefined);
// Signal is now logged (though we can't verify without mocking Logger)
await service.onApplicationShutdown('SIGTERM');
expect(mockGateway.disconnect).toHaveBeenCalled();
});
});
});

View File

@ -8,8 +8,10 @@ import {
@Injectable()
export class FabricService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(FabricService.name);
private readonly gateway = new FabricGateway();
constructor(
private readonly gateway: FabricGateway,
private readonly logger: Logger,
) {}
async onModuleInit() {
this.logger.log('Attempting to connect to Fabric network...');
@ -17,16 +19,43 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown {
await this.gateway.connect();
this.logger.log('Successfully connected to Fabric network.');
} catch (error) {
this.logger.error('Failed to connect to Fabric network:', error);
throw new Error('Failed to connect to Fabric network');
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
this.logger.error(
`Failed to connect to Fabric network: ${errorMessage}`,
error instanceof Error ? error.stack : undefined,
);
throw new Error(`Failed to connect to Fabric network: ${errorMessage}`);
}
}
async onApplicationShutdown(signal?: string) {
this.logger.log('Disconnecting from Fabric network...');
await this.gateway.disconnect();
this.logger.log(
`Disconnecting from Fabric network...${signal ? ` (signal: ${signal})` : ''}`,
);
try {
await this.gateway.disconnect();
this.logger.log('Successfully disconnected from Fabric network.');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
this.logger.error(
`Failed to disconnect from Fabric network: ${errorMessage}`,
error instanceof Error ? error.stack : undefined,
);
}
}
/**
* Menyimpan entri log ke blockchain Fabric.
*
* @param id - ID unik log (harus tidak kosong, divalidasi oleh method pemanggil). Contoh nilai: 'REKAM_12XX'
* @param event - Jenis event (harus tidak kosong, divalidasi oleh method pemanggil). Contoh Nilai: 'CREATE'
* @param user_id - ID pengguna (harus valid, divalidasi oleh method pemanggil). Contoh Nilai: '1'
* @param payload - Payload string berupa Hash dari payload data (method hanya menerima string berupa hash). Contoh Nilai: '4f9075ab9fc724a0xxxx'
*
* @throws Error if Fabric gateway fails
*/
async storeLog(id: string, event: string, user_id: string, payload: string) {
this.logger.log(`Storing log with ID: ${id}`);
return this.gateway.storeLog(id, event, user_id, payload);